diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0a216ad4bd39e0ea3949eca95f8f7461271ba8de..1937b2693e3923e46efc59ab959a7939b733cbdd 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -208,7 +208,7 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenAgentDiff, window, cx| { let thread = workspace .panel::(cx) - .and_then(|panel| panel.read(cx).active_thread_view().cloned()) + .and_then(|panel| panel.read(cx).active_connection_view().cloned()) .and_then(|thread_view| { thread_view .read(cx) @@ -570,6 +570,7 @@ pub struct AgentPanel { start_thread_in: StartThreadIn, worktree_creation_status: Option, _thread_view_subscription: Option, + _active_thread_focus_subscription: Option, _worktree_creation_task: Option>, show_trust_workspace_message: bool, last_configuration_error_telemetry: Option, @@ -898,6 +899,7 @@ impl AgentPanel { start_thread_in: StartThreadIn::default(), worktree_creation_status: None, _thread_view_subscription: None, + _active_thread_focus_subscription: None, _worktree_creation_task: None, show_trust_workspace_message: false, last_configuration_error_telemetry: None, @@ -988,7 +990,7 @@ impl AgentPanel { .unwrap_or(false) } - pub(crate) fn active_thread_view(&self) -> Option<&Entity> { + pub fn active_connection_view(&self) -> Option<&Entity> { match &self.active_view { ActiveView::AgentThread { server_view, .. } => Some(server_view), ActiveView::Uninitialized @@ -1173,7 +1175,7 @@ impl AgentPanel { } fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context) { - let Some(thread_view) = self.active_thread_view() else { + let Some(thread_view) = self.active_connection_view() else { return; }; @@ -1432,7 +1434,7 @@ impl AgentPanel { cx: &mut Context, ) { if let Some(workspace) = self.workspace.upgrade() - && let Some(thread_view) = self.active_thread_view() + && let Some(thread_view) = self.active_connection_view() && let Some(active_thread) = thread_view.read(cx).active_thread().cloned() { active_thread.update(cx, |thread, cx| { @@ -1763,6 +1765,12 @@ impl AgentPanel { ActiveView::AgentThread { server_view } => { self._thread_view_subscription = Self::subscribe_to_active_thread_view(server_view, window, cx); + let focus_handle = server_view.focus_handle(cx); + self._active_thread_focus_subscription = + Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| { + cx.emit(AgentPanelEvent::ThreadFocused); + cx.notify(); + })); Some( cx.observe_in(server_view, window, |this, server_view, window, cx| { this._thread_view_subscription = @@ -1775,6 +1783,7 @@ impl AgentPanel { } _ => { self._thread_view_subscription = None; + self._active_thread_focus_subscription = None; None } }; @@ -2035,6 +2044,7 @@ impl AgentPanel { .map(|t| t.read(cx).id.clone()) == Some(session_id.clone()) { + cx.emit(AgentPanelEvent::ActiveViewChanged); return; } } @@ -2642,6 +2652,7 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { pub enum AgentPanelEvent { ActiveViewChanged, + ThreadFocused, BackgroundThreadChanged, } @@ -3523,7 +3534,7 @@ impl AgentPanel { }); let is_thread_loading = self - .active_thread_view() + .active_connection_view() .map(|thread| thread.read(cx).is_loading()) .unwrap_or(false); @@ -4077,7 +4088,7 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { - if let Some(thread_view) = this.active_thread_view() { + if let Some(thread_view) = this.active_connection_view() { thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx)) } })) @@ -4263,7 +4274,7 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(thread_view) = panel.active_thread_view() { + if let Some(thread_view) = panel.active_connection_view() { thread_view.update(cx, |thread_view, cx| { thread_view.insert_selections(window, cx); }); @@ -4301,7 +4312,7 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(thread_view) = panel.active_thread_view() { + if let Some(thread_view) = panel.active_connection_view() { thread_view.update(cx, |thread_view, cx| { thread_view.insert_terminal_text(text, window, cx); }); @@ -4367,7 +4378,7 @@ impl AgentPanel { /// This is a test-only accessor that exposes the private `active_thread_view()` /// method for test assertions. Not compiled into production builds. pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { - self.active_thread_view() + self.active_connection_view() } /// Sets the start_thread_in value directly, bypassing validation. @@ -4552,7 +4563,7 @@ mod tests { "workspace A agent type should be restored" ); assert!( - panel.active_thread_view().is_some(), + panel.active_connection_view().is_some(), "workspace A should have its active thread restored" ); }); @@ -4572,7 +4583,7 @@ mod tests { "workspace B agent type should be restored" ); assert!( - panel.active_thread_view().is_none(), + panel.active_connection_view().is_none(), "workspace B should have no active thread" ); }); @@ -4709,7 +4720,7 @@ mod tests { send_message(&panel, &mut cx); let weak_view_a = panel.read_with(&cx, |panel, _cx| { - panel.active_thread_view().unwrap().downgrade() + panel.active_connection_view().unwrap().downgrade() }); // Thread A should be idle (auto-completed via set_next_prompt_updates). diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index e7e9403e052f6578ab20982fbb27c7c6a29d1a80..6ba0f94934300d02c5a921af797a62f1a8756d76 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -399,7 +399,10 @@ impl ConnectionView { enum ServerState { Loading(Entity), - LoadError(LoadError), + LoadError { + error: LoadError, + session_id: Option, + }, Connected(ConnectedServerState), } @@ -430,6 +433,7 @@ impl AuthState { } struct LoadingView { + session_id: Option, title: SharedString, _load_task: Task<()>, _update_title_task: Task>, @@ -572,12 +576,18 @@ impl ConnectionView { window: &mut Window, cx: &mut Context, ) -> ServerState { + let session_id = resume_thread + .as_ref() + .map(|thread| thread.session_id.clone()); if project.read(cx).is_via_collab() && agent.clone().downcast::().is_none() { - return ServerState::LoadError(LoadError::Other( - "External agents are not yet supported in shared projects.".into(), - )); + return ServerState::LoadError { + error: LoadError::Other( + "External agents are not yet supported in shared projects.".into(), + ), + session_id, + }; } let mut worktrees = project.read(cx).visible_worktrees(cx).collect::>(); // Pick the first non-single-file worktree for the root directory if there are any, @@ -633,17 +643,18 @@ impl ConnectionView { ); let connect_task = agent.connect(delegate, cx); + let load_session_id = session_id.clone(); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok(connection) => connection, Err(err) => { this.update_in(cx, |this, window, cx| { if err.downcast_ref::().is_some() { - this.handle_load_error(err, window, cx); + this.handle_load_error(load_session_id.clone(), err, window, cx); } else if let Some(active) = this.active_thread() { active.update(cx, |active, cx| active.handle_thread_error(err, cx)); } else { - this.handle_load_error(err, window, cx); + this.handle_load_error(load_session_id.clone(), err, window, cx); } cx.notify(); }) @@ -756,7 +767,7 @@ impl ConnectionView { ); } Err(err) => { - this.handle_load_error(err, window, cx); + this.handle_load_error(load_session_id.clone(), err, window, cx); } }; }) @@ -792,6 +803,7 @@ impl ConnectionView { }); LoadingView { + session_id, title: "Loading…".into(), _load_task: load_task, _update_title_task: update_title_task, @@ -1086,6 +1098,7 @@ impl ConnectionView { fn handle_load_error( &mut self, + session_id: Option, err: anyhow::Error, window: &mut Window, cx: &mut Context, @@ -1106,7 +1119,13 @@ impl ConnectionView { LoadError::Other(format!("{:#}", err).into()) }; self.emit_load_error_telemetry(&load_error); - self.set_server_state(ServerState::LoadError(load_error), cx); + self.set_server_state( + ServerState::LoadError { + error: load_error, + session_id, + }, + cx, + ); } fn handle_agent_servers_updated( @@ -1121,7 +1140,7 @@ impl ConnectionView { // This handles the case where a thread is restored before authentication completes. let should_retry = match &self.server_state { ServerState::Loading(_) => false, - ServerState::LoadError(_) => true, + ServerState::LoadError { .. } => true, ServerState::Connected(connected) => { connected.auth_state.is_ok() && connected.has_thread_error(cx) } @@ -1145,7 +1164,7 @@ impl ConnectionView { match &self.server_state { ServerState::Connected(_) => "New Thread".into(), ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(), - ServerState::LoadError(error) => match error { + ServerState::LoadError { error, .. } => match error { LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), LoadError::FailedToInstall(_) => { format!("Failed to Install {}", self.agent.name()).into() @@ -1164,6 +1183,17 @@ impl ConnectionView { } } + // The parent ID is None if we haven't created a thread yet + pub fn parent_id(&self, cx: &App) -> Option { + match &self.server_state { + ServerState::Connected(_) => self + .parent_thread(cx) + .map(|thread| thread.read(cx).id.clone()), + ServerState::Loading(loading) => loading.read(cx).session_id.clone(), + ServerState::LoadError { session_id, .. } => session_id.clone(), + } + } + pub fn is_loading(&self) -> bool { matches!(self.server_state, ServerState::Loading { .. }) } @@ -1361,7 +1391,13 @@ impl ConnectionView { self.focus_handle.focus(window, cx) } } - self.set_server_state(ServerState::LoadError(error.clone()), cx); + self.set_server_state( + ServerState::LoadError { + error: error.clone(), + session_id: Some(thread_id), + }, + cx, + ); } AcpThreadEvent::TitleUpdated => { let title = thread.read(cx).title(); @@ -2635,7 +2671,7 @@ impl Render for ConnectionView { .flex_1() // .child(self.render_recent_history(cx)) .into_any(), - ServerState::LoadError(e) => v_flex() + ServerState::LoadError { error: e, .. } => v_flex() .flex_1() .size_full() .items_center() @@ -3126,7 +3162,10 @@ pub(crate) mod tests { "Tab title should show the agent name with an error prefix" ); match &view.server_state { - ServerState::LoadError(LoadError::Other(msg)) => { + ServerState::LoadError { + error: LoadError::Other(msg), + .. + } => { assert!( msg.contains("Invalid gzip header"), "Error callout should contain the underlying extraction error, got: {msg}" @@ -3136,7 +3175,7 @@ pub(crate) mod tests { "Expected LoadError::Other, got: {}", match other { ServerState::Loading(_) => "Loading (stuck!)", - ServerState::LoadError(_) => "LoadError (wrong variant)", + ServerState::LoadError { .. } => "LoadError (wrong variant)", ServerState::Connected(_) => "Connected", } ), diff --git a/crates/gpui/examples/active_state_bug.rs b/crates/gpui/examples/active_state_bug.rs new file mode 100644 index 0000000000000000000000000000000000000000..f767ed27e456ec65858b72a4df89fab65e7fd1f3 --- /dev/null +++ b/crates/gpui/examples/active_state_bug.rs @@ -0,0 +1,47 @@ +/// Click the button — the `.active()` background gets stuck on every other click. +use gpui::*; +use gpui_platform::application; + +struct Example; + +impl Render for Example { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + // Colors from Zed's default dark theme + let bg = hsla(215. / 360., 0.12, 0.15, 1.); + let text = hsla(221. / 360., 0.11, 0.86, 1.); + let hover = hsla(225. / 360., 0.118, 0.267, 1.); + let active = hsla(220. / 360., 0.118, 0.20, 1.); + + div().bg(bg).size_full().p_1().child( + div() + .id("button") + .px_2() + .py_0p5() + .rounded_md() + .text_sm() + .text_color(text) + .hover(|s| s.bg(hover)) + .active(|s| s.bg(active)) + .on_click(|_, _, _| {}) + .child("Click me"), + ) + } +} + +fn main() { + application().run(|cx: &mut App| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(200.), px(60.)), + cx, + ))), + ..Default::default() + }, + |_, cx| cx.new(|_| Example), + ) + .unwrap(); + cx.activate(true); + }); +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 58f11a7fa1fb876ef4b4ef80fedf1948423a24f5..3599affc3c792f3c93b3b94cfc44740d7c38caf7 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2517,18 +2517,24 @@ impl Interactivity { ); } + // We unconditionally bind both the mouse up and mouse down active state handlers + // Because we might not get a chance to render a frame before the mouse up event arrives. let active_state = element_state .clicked_state .get_or_insert_with(Default::default) .clone(); - if active_state.borrow().is_clicked() { + + { + let active_state = active_state.clone(); window.on_mouse_event(move |_: &MouseUpEvent, phase, window, _cx| { - if phase == DispatchPhase::Capture { + if phase == DispatchPhase::Capture && active_state.borrow().is_clicked() { *active_state.borrow_mut() = ElementClickedState::default(); window.refresh(); } }); - } else { + } + + { let active_group_hitbox = self .group_active_style .as_ref() diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index f0722a5791f6eecf873703bc5337890329d310c8..602fe12255bc3b8c5cee3445b96795475fcd7026 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -21,6 +21,7 @@ agent-client-protocol.workspace = true agent_ui.workspace = true chrono.workspace = true editor.workspace = true +feature_flags.workspace = true fs.workspace = true gpui.workspace = true menu.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 3fee8ff811a7c4207f050348056f06a8b51a70e7..1a44724b5532ec3a6f644adc16925a3dcf942c88 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4,10 +4,11 @@ use agent_client_protocol as acp; use agent_ui::{AgentPanel, AgentPanelEvent, NewThread}; use chrono::Utc; use editor::{Editor, EditorElement, EditorStyle}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, - Pixels, Render, SharedString, Subscription, TextStyle, WeakEntity, Window, actions, list, - prelude::*, px, relative, rems, + Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, + relative, rems, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; @@ -22,8 +23,8 @@ use ui::{ }; use util::path_list::PathList; use workspace::{ - FocusWorkspaceSidebar, MultiWorkspace, Sidebar as WorkspaceSidebar, SidebarEvent, - ToggleWorkspaceSidebar, Workspace, + FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, + SidebarEvent, ToggleWorkspaceSidebar, Workspace, }; use zed_actions::editor::{MoveDown, MoveUp}; @@ -70,7 +71,7 @@ enum ListEntry { ProjectHeader { path_list: PathList, label: SharedString, - workspace_index: usize, + workspace: Entity, highlight_positions: Vec, }, Thread { @@ -79,7 +80,7 @@ enum ListEntry { icon_from_external_svg: Option, status: AgentThreadStatus, diff_stats: Option<(usize, usize)>, - workspace_index: usize, + workspace: Entity, is_live: bool, is_background: bool, highlight_positions: Vec, @@ -90,6 +91,7 @@ enum ListEntry { }, NewThread { path_list: PathList, + workspace: Entity, }, } @@ -157,20 +159,6 @@ fn workspace_path_list_and_label( (PathList::new(&paths), label) } -fn workspace_index_for_path_list( - workspaces: &[Entity], - path_list: &PathList, - cx: &App, -) -> Option { - workspaces - .iter() - .enumerate() - .find_map(|(index, workspace)| { - let (candidate, _) = workspace_path_list_and_label(workspace, cx); - (candidate == *path_list).then_some(index) - }) -} - pub struct Sidebar { multi_workspace: WeakEntity, width: Pixels, @@ -178,13 +166,14 @@ pub struct Sidebar { filter_editor: Entity, list_state: ListState, contents: SidebarContents, + /// The index of the list item that currently has the keyboard focus + /// + /// Note: This is NOT the same as the active item. selection: Option, + focused_thread: Option, + active_entry_index: Option, collapsed_groups: HashSet, expanded_groups: HashSet, - _subscriptions: Vec, - _project_subscriptions: Vec, - _agent_panel_subscriptions: Vec, - _thread_store_subscription: Option, } impl EventEmitter for Sidebar {} @@ -205,22 +194,32 @@ impl Sidebar { editor }); - let observe_subscription = cx.observe_in( + cx.subscribe_in( &multi_workspace, window, - |this, _multi_workspace, window, cx| { - this.update_entries(window, cx); + |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { + MultiWorkspaceEvent::ActiveWorkspaceChanged => { + this.focused_thread = None; + this.update_entries(cx); + } + MultiWorkspaceEvent::WorkspaceAdded(workspace) => { + this.subscribe_to_workspace(workspace, window, cx); + this.update_entries(cx); + } + MultiWorkspaceEvent::WorkspaceRemoved(_) => { + this.update_entries(cx); + } }, - ); + ) + .detach(); - let filter_subscription = cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { + cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { if let editor::EditorEvent::BufferEdited = event { let query = this.filter_editor.read(cx).text(cx); if !query.is_empty() { this.selection.take(); } - this.rebuild_contents(cx); - this.list_state.reset(this.contents.entries.len()); + this.update_entries(cx); if !query.is_empty() { this.selection = this .contents @@ -235,11 +234,30 @@ impl Sidebar { } }); } - cx.notify(); } + }) + .detach(); + + let thread_store = ThreadStore::global(cx); + cx.observe_in(&thread_store, window, |this, _, _window, cx| { + this.update_entries(cx); + }) + .detach(); + + cx.observe_flag::(window, |_is_enabled, this, _window, cx| { + this.update_entries(cx); + }) + .detach(); + + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + cx.defer_in(window, move |this, window, cx| { + for workspace in &workspaces { + this.subscribe_to_workspace(workspace, window, cx); + } + this.update_entries(cx); }); - let mut this = Self { + Self { multi_workspace: multi_workspace.downgrade(), width: DEFAULT_WIDTH, focus_handle, @@ -247,91 +265,86 @@ impl Sidebar { list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), contents: SidebarContents::default(), selection: None, + focused_thread: None, + active_entry_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashSet::new(), - _subscriptions: vec![observe_subscription, filter_subscription], - _project_subscriptions: Vec::new(), - _agent_panel_subscriptions: Vec::new(), - _thread_store_subscription: None, - }; - this.update_entries(window, cx); - this + } } - fn subscribe_to_projects( - &mut self, + fn subscribe_to_workspace( + &self, + workspace: &Entity, window: &mut Window, cx: &mut Context, - ) -> Vec { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return Vec::new(); - }; - let projects: Vec<_> = multi_workspace - .read(cx) - .workspaces() - .iter() - .map(|w| w.read(cx).project().clone()) - .collect(); + ) { + let project = workspace.read(cx).project().clone(); + cx.subscribe_in( + &project, + window, + |this, _project, event, _window, cx| match event { + ProjectEvent::WorktreeAdded(_) + | ProjectEvent::WorktreeRemoved(_) + | ProjectEvent::WorktreeOrderChanged => { + this.update_entries(cx); + } + _ => {} + }, + ) + .detach(); - projects - .iter() - .map(|project| { - cx.subscribe_in( - project, - window, - |this, _project, event, window, cx| match event { - ProjectEvent::WorktreeAdded(_) - | ProjectEvent::WorktreeRemoved(_) - | ProjectEvent::WorktreeOrderChanged => { - this.update_entries(window, cx); - } - _ => {} - }, - ) - }) - .collect() + cx.subscribe_in( + workspace, + window, + |this, _workspace, event: &workspace::Event, window, cx| { + if let workspace::Event::PanelAdded(view) = event { + if let Ok(agent_panel) = view.clone().downcast::() { + this.subscribe_to_agent_panel(&agent_panel, window, cx); + } + } + }, + ) + .detach(); + + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + self.subscribe_to_agent_panel(&agent_panel, window, cx); + } } - fn subscribe_to_agent_panels( - &mut self, + fn subscribe_to_agent_panel( + &self, + agent_panel: &Entity, window: &mut Window, cx: &mut Context, - ) -> Vec { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return Vec::new(); - }; - let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().to_vec(); - - workspaces - .iter() - .map(|workspace| { - if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - cx.subscribe_in( - &agent_panel, - window, - |this, _, _event: &AgentPanelEvent, window, cx| { - this.update_entries(window, cx); - }, - ) - } else { - cx.observe_in(workspace, window, |this, _, window, cx| { - this.update_entries(window, cx); - }) + ) { + cx.subscribe_in( + agent_panel, + window, + |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged => { + if let Some(thread) = agent_panel.read(cx).active_connection_view() + && let Some(session_id) = thread.read(cx).parent_id(cx) + { + this.focused_thread = Some(session_id); + } + this.update_entries(cx); } - }) - .collect() - } - - fn subscribe_to_thread_store(&mut self, window: &mut Window, cx: &mut Context) { - if self._thread_store_subscription.is_some() { - return; - } - if let Some(thread_store) = ThreadStore::try_global(cx) { - self._thread_store_subscription = - Some(cx.observe_in(&thread_store, window, |this, _, window, cx| { - this.update_entries(window, cx); - })); - } + AgentPanelEvent::ThreadFocused => { + let new_focused = agent_panel + .read(cx) + .active_connection_view() + .and_then(|thread| thread.read(cx).parent_id(cx)); + if new_focused != this.focused_thread { + this.focused_thread = new_focused; + this.update_entries(cx); + } + } + AgentPanelEvent::BackgroundThreadChanged => { + this.update_entries(cx); + } + }, + ) + .detach(); } fn all_thread_infos_for_workspace( @@ -386,13 +399,6 @@ impl Sidebar { let mw = multi_workspace.read(cx); let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - let active_workspace_index = active_workspace - .and_then(|active| { - workspaces - .iter() - .position(|w| w.entity_id() == active.entity_id()) - }) - .unwrap_or(0); let thread_store = ThreadStore::try_global(cx); let query = self.filter_editor.read(cx).text(cx); @@ -416,7 +422,7 @@ impl Sidebar { let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; - for (index, workspace) in workspaces.iter().enumerate() { + for workspace in workspaces.iter() { let (path_list, label) = workspace_path_list_and_label(workspace, cx); let is_collapsed = self.collapsed_groups.contains(&path_list); @@ -433,7 +439,7 @@ impl Sidebar { icon_from_external_svg: None, status: AgentThreadStatus::default(), diff_stats: None, - workspace_index: index, + workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -455,7 +461,7 @@ impl Sidebar { status, icon, icon_from_external_svg, - workspace_index: _, + workspace: _, is_live, is_background, .. @@ -473,7 +479,7 @@ impl Sidebar { // Update notification state for live threads. for thread in &threads { if let ListEntry::Thread { - workspace_index, + workspace: thread_workspace, session_info, status, is_background, @@ -484,13 +490,19 @@ impl Sidebar { if *is_background && *status == AgentThreadStatus::Completed { notified_threads.insert(session_id.clone()); } else if *status == AgentThreadStatus::Completed - && *workspace_index != active_workspace_index + && active_workspace + .as_ref() + .is_none_or(|active| active != thread_workspace) && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { notified_threads.insert(session_id.clone()); } - if *workspace_index == active_workspace_index && !*is_background { + if active_workspace + .as_ref() + .is_some_and(|active| active == thread_workspace) + && !*is_background + { notified_threads.remove(session_id); } } @@ -540,7 +552,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace_index: index, + workspace: workspace.clone(), highlight_positions: workspace_highlight_positions, }); entries.extend(matched_threads); @@ -548,7 +560,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace_index: index, + workspace: workspace.clone(), highlight_positions: Vec::new(), }); @@ -578,6 +590,7 @@ impl Sidebar { if total == 0 { entries.push(ListEntry::NewThread { path_list: path_list.clone(), + workspace: workspace.clone(), }); } } @@ -599,40 +612,46 @@ impl Sidebar { }; } - fn update_entries(&mut self, window: &mut Window, cx: &mut Context) { - let multi_workspace = self.multi_workspace.clone(); - cx.defer_in(window, move |this, window, cx| { - let Some(multi_workspace) = multi_workspace.upgrade() else { - return; - }; - if !multi_workspace.read(cx).multi_workspace_enabled(cx) { - return; - } - - this._project_subscriptions = this.subscribe_to_projects(window, cx); - this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx); - this.subscribe_to_thread_store(window, cx); + fn update_entries(&mut self, cx: &mut Context) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + if !multi_workspace.read(cx).multi_workspace_enabled(cx) { + return; + } - let had_notifications = this.has_notifications(cx); + let had_notifications = self.has_notifications(cx); - this.rebuild_contents(cx); + self.rebuild_contents(cx); + self.recompute_active_entry_index(cx); - this.list_state.reset(this.contents.entries.len()); + self.list_state.reset(self.contents.entries.len()); - if let Some(selection) = this.selection { - if selection >= this.contents.entries.len() { - this.selection = this.contents.entries.len().checked_sub(1); - } - } + if had_notifications != self.has_notifications(cx) { + multi_workspace.update(cx, |_, cx| { + cx.notify(); + }); + } - if had_notifications != this.has_notifications(cx) { - multi_workspace.update(cx, |_, cx| { - cx.notify(); - }); - } + cx.notify(); + } - cx.notify(); - }); + fn recompute_active_entry_index(&mut self, cx: &App) { + self.active_entry_index = if let Some(session_id) = &self.focused_thread { + self.contents.entries.iter().position(|entry| { + matches!(entry, ListEntry::Thread { session_info, .. } if &session_info.session_id == session_id) + }) + } else { + let active_workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().clone()); + active_workspace.and_then(|active| { + self.contents.entries.iter().position(|entry| { + matches!(entry, ListEntry::ProjectHeader { workspace, .. } if workspace == &active) + }) + }) + }; } fn render_list_entry( @@ -646,6 +665,7 @@ impl Sidebar { }; let is_focused = self.focus_handle.is_focused(window) || self.filter_editor.focus_handle(cx).is_focused(window); + // is_selected means the keyboard selector is here. let is_selected = is_focused && self.selection == Some(ix); let is_group_header_after_first = @@ -655,23 +675,17 @@ impl Sidebar { ListEntry::ProjectHeader { path_list, label, - workspace_index, - highlight_positions, - } => self.render_project_header( - ix, - path_list, - label, - *workspace_index, + workspace, highlight_positions, - is_selected, - cx, - ), + } => { + self.render_project_header(ix, path_list, label, workspace, highlight_positions, cx) + } ListEntry::Thread { session_info, icon, icon_from_external_svg, status, - workspace_index, + workspace, highlight_positions, .. } => self.render_thread( @@ -680,7 +694,7 @@ impl Sidebar { *icon, icon_from_external_svg.clone(), *status, - *workspace_index, + workspace, highlight_positions, is_selected, cx, @@ -689,11 +703,14 @@ impl Sidebar { path_list, remaining_count, } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx), - ListEntry::NewThread { path_list } => { - self.render_new_thread(ix, path_list, is_selected, cx) - } + ListEntry::NewThread { + path_list, + workspace, + } => self.render_new_thread(ix, path_list, workspace, is_selected, cx), }; + // add the blue border here, not in the sub methods + if is_group_header_after_first { v_flex() .w_full() @@ -711,9 +728,8 @@ impl Sidebar { ix: usize, path_list: &PathList, label: &SharedString, - workspace_index: usize, + workspace: &Entity, highlight_positions: &[usize], - is_selected: bool, cx: &mut Context, ) -> AnyElement { let id = SharedString::from(format!("project-header-{}", ix)); @@ -726,17 +742,24 @@ impl Sidebar { } else { IconName::ChevronDown }; - let path_list_for_new_thread = path_list.clone(); - let path_list_for_remove = path_list.clone(); + let workspace_for_new_thread = workspace.clone(); + let workspace_for_remove = workspace.clone(); + let workspace_for_activate = workspace.clone(); let path_list_for_toggle = path_list.clone(); - let workspace_count = self - .multi_workspace - .upgrade() + let multi_workspace = self.multi_workspace.upgrade(); + let workspace_count = multi_workspace + .as_ref() .map_or(0, |mw| mw.read(cx).workspaces().len()); + let is_active_workspace = self.focused_thread.is_none() + && multi_workspace + .as_ref() + .is_some_and(|mw| mw.read(cx).workspace() == workspace); + + // TODO: if is_selected, draw a blue border around the item. ListItem::new(id) .group_name(&group) - .toggle_state(is_selected) + .toggle_state(is_active_workspace) .child( h_flex() .px_1() @@ -783,7 +806,7 @@ impl Sidebar { .tooltip(Tooltip::text("New Thread")) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.create_new_thread(&path_list_for_new_thread, window, cx); + this.create_new_thread(&workspace_for_new_thread, window, cx); })), ) .when(workspace_count > 1, |this| { @@ -797,7 +820,7 @@ impl Sidebar { .tooltip(Tooltip::text("Remove Project")) .on_click(cx.listener( move |this, _, window, cx| { - this.remove_workspace(&path_list_for_remove, window, cx); + this.remove_workspace(&workspace_for_remove, window, cx); }, )), ) @@ -805,14 +828,14 @@ impl Sidebar { ) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.activate_workspace(workspace_index, window, cx); + this.activate_workspace(&workspace_for_activate, window, cx); })) .into_any_element() } fn activate_workspace( &mut self, - workspace_index: usize, + workspace: &Entity, window: &mut Window, cx: &mut Context, ) { @@ -820,36 +843,43 @@ impl Sidebar { return; }; + self.focused_thread = None; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), cx); + }); + multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate_index(workspace_index, window, cx); + multi_workspace.focus_active_workspace(window, cx); }); } fn remove_workspace( &mut self, - path_list: &PathList, + workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - - let Some(workspace_index) = workspace_index_for_path_list(&workspaces, path_list, cx) - else { - return; - }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.remove_workspace(workspace_index, window, cx); + let Some(index) = multi_workspace + .workspaces() + .iter() + .position(|w| w == workspace) + else { + return; + }; + multi_workspace.remove_workspace(index, window, cx); }); } fn toggle_collapse( &mut self, path_list: &PathList, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { if self.collapsed_groups.contains(path_list) { @@ -857,7 +887,7 @@ impl Sidebar { } else { self.collapsed_groups.insert(path_list.clone()); } - self.update_entries(window, cx); + self.update_entries(cx); } fn focus_in(&mut self, _window: &mut Window, cx: &mut Context) { @@ -869,7 +899,7 @@ impl Sidebar { fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.reset_filter_editor_text(window, cx) { - self.update_entries(window, cx); + self.update_entries(cx); } else { self.focus_handle.focus(window, cx); } @@ -948,29 +978,27 @@ impl Sidebar { }; match entry { - ListEntry::ProjectHeader { - workspace_index, .. - } => { - let workspace_index = *workspace_index; - self.activate_workspace(workspace_index, window, cx); + ListEntry::ProjectHeader { workspace, .. } => { + let workspace = workspace.clone(); + self.activate_workspace(&workspace, window, cx); } ListEntry::Thread { session_info, - workspace_index, + workspace, .. } => { let session_info = session_info.clone(); - let workspace_index = *workspace_index; - self.activate_thread(session_info, workspace_index, window, cx); + let workspace = workspace.clone(); + self.activate_thread(session_info, &workspace, window, cx); } ListEntry::ViewMore { path_list, .. } => { let path_list = path_list.clone(); self.expanded_groups.insert(path_list); - self.update_entries(window, cx); + self.update_entries(cx); } - ListEntry::NewThread { path_list } => { - let path_list = path_list.clone(); - self.create_new_thread(&path_list, window, cx); + ListEntry::NewThread { workspace, .. } => { + let workspace = workspace.clone(); + self.create_new_thread(&workspace, window, cx); } } } @@ -978,7 +1006,7 @@ impl Sidebar { fn activate_thread( &mut self, session_info: acp_thread::AgentSessionInfo, - workspace_index: usize, + workspace: &Entity, window: &mut Window, cx: &mut Context, ) { @@ -987,22 +1015,24 @@ impl Sidebar { }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate_index(workspace_index, window, cx); + multi_workspace.activate(workspace.clone(), cx); }); - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - if let Some(workspace) = workspaces.get(workspace_index) { - if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - agent_panel.update(cx, |panel, cx| { - panel.load_agent_thread(session_info, window, cx); - }); - } + + workspace.update(cx, |workspace, cx| { + workspace.open_panel::(window, cx); + }); + + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + agent_panel.update(cx, |panel, cx| { + panel.load_agent_thread(session_info, window, cx); + }); } } fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; @@ -1012,7 +1042,7 @@ impl Sidebar { if self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.remove(&path_list); - self.update_entries(window, cx); + self.update_entries(cx); } else if ix + 1 < self.contents.entries.len() { self.selection = Some(ix + 1); self.list_state.scroll_to_reveal_item(ix + 1); @@ -1026,7 +1056,7 @@ impl Sidebar { fn collapse_selected_entry( &mut self, _: &CollapseSelectedEntry, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { let Some(ix) = self.selection else { return }; @@ -1036,7 +1066,7 @@ impl Sidebar { if !self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.insert(path_list); - self.update_entries(window, cx); + self.update_entries(cx); } } Some( @@ -1049,7 +1079,7 @@ impl Sidebar { let path_list = path_list.clone(); self.selection = Some(i); self.collapsed_groups.insert(path_list); - self.update_entries(window, cx); + self.update_entries(cx); break; } } @@ -1065,9 +1095,9 @@ impl Sidebar { icon: IconName, icon_from_external_svg: Option, status: AgentThreadStatus, - workspace_index: usize, + workspace: &Entity, highlight_positions: &[usize], - is_selected: bool, + _is_selected: bool, cx: &mut Context, ) -> AnyElement { let has_notification = self.contents.is_thread_notified(&session_info.session_id); @@ -1077,6 +1107,7 @@ impl Sidebar { .clone() .unwrap_or_else(|| "Untitled".into()); let session_info = session_info.clone(); + let workspace = workspace.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); ThreadItem::new(id, title) @@ -1087,10 +1118,10 @@ impl Sidebar { .highlight_positions(highlight_positions.to_vec()) .status(status) .notified(has_notification) - .selected(is_selected) + .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.activate_thread(session_info.clone(), workspace_index, window, cx); + this.activate_thread(session_info.clone(), &workspace, window, cx); })) .into_any_element() } @@ -1147,55 +1178,47 @@ impl Sidebar { .child(Label::new("View More")) .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)), ) - .on_click(cx.listener(move |this, _, window, cx| { + .on_click(cx.listener(move |this, _, _window, cx| { this.selection = None; this.expanded_groups.insert(path_list.clone()); - this.update_entries(window, cx); + this.update_entries(cx); })) .into_any_element() } fn create_new_thread( &mut self, - path_list: &PathList, + workspace: &Entity, window: &mut Window, cx: &mut Context, ) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - - let workspace_index = workspace_index_for_path_list(&workspaces, path_list, cx); - - let Some(workspace_index) = workspace_index else { - return; - }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate_index(workspace_index, window, cx); + multi_workspace.activate(workspace.clone(), cx); }); - if let Some(workspace) = workspaces.get(workspace_index) { - workspace.update(cx, |workspace, cx| { - if let Some(agent_panel) = workspace.panel::(cx) { - agent_panel.update(cx, |panel, cx| { - panel.new_thread(&NewThread, window, cx); - }); - } - workspace.focus_panel::(window, cx); - }); - } + workspace.update(cx, |workspace, cx| { + if let Some(agent_panel) = workspace.panel::(cx) { + agent_panel.update(cx, |panel, cx| { + panel.new_thread(&NewThread, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); } fn render_new_thread( &self, ix: usize, - path_list: &PathList, + _path_list: &PathList, + workspace: &Entity, is_selected: bool, cx: &mut Context, ) -> AnyElement { - let path_list = path_list.clone(); + let workspace = workspace.clone(); div() .w_full() @@ -1214,7 +1237,7 @@ impl Sidebar { .toggle_state(is_selected) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.create_new_thread(&path_list, window, cx); + this.create_new_thread(&workspace, window, cx); })), ) .into_any_element() @@ -1390,7 +1413,7 @@ impl Render for Sidebar { .tooltip(Tooltip::text("Clear Search")) .on_click(cx.listener(|this, _, window, cx| { this.reset_filter_editor_text(window, cx); - this.update_entries(window, cx); + this.update_entries(cx); })), ) }), @@ -1475,10 +1498,9 @@ mod tests { multi_workspace: &Entity, cx: &mut gpui::VisualTestContext, ) -> Entity { - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) - }); + let multi_workspace = multi_workspace.clone(); + let sidebar = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); multi_workspace.update_in(cx, |mw, window, cx| { mw.register_sidebar(sidebar.clone(), window, cx); }); @@ -1819,6 +1841,7 @@ mod tests { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); @@ -1832,7 +1855,7 @@ mod tests { ListEntry::ProjectHeader { path_list: expanded_path.clone(), label: "expanded-project".into(), - workspace_index: 0, + workspace: workspace.clone(), highlight_positions: Vec::new(), }, // Thread with default (Completed) status, not active @@ -1848,7 +1871,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Completed, diff_stats: None, - workspace_index: 0, + workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -1866,7 +1889,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Running, diff_stats: None, - workspace_index: 0, + workspace: workspace.clone(), is_live: true, is_background: false, highlight_positions: Vec::new(), @@ -1884,7 +1907,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Error, diff_stats: None, - workspace_index: 1, + workspace: workspace.clone(), is_live: true, is_background: false, highlight_positions: Vec::new(), @@ -1902,7 +1925,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::WaitingForConfirmation, diff_stats: None, - workspace_index: 0, + workspace: workspace.clone(), is_live: false, is_background: false, highlight_positions: Vec::new(), @@ -1920,7 +1943,7 @@ mod tests { icon_from_external_svg: None, status: AgentThreadStatus::Completed, diff_stats: None, - workspace_index: 1, + workspace: workspace.clone(), is_live: true, is_background: true, highlight_positions: Vec::new(), @@ -1934,7 +1957,7 @@ mod tests { ListEntry::ProjectHeader { path_list: collapsed_path.clone(), label: "collapsed-project".into(), - workspace_index: 1, + workspace: workspace.clone(), highlight_positions: Vec::new(), }, ]; @@ -2129,6 +2152,16 @@ mod tests { multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), 0 ); + + // Focus should have moved out of the sidebar to the workspace center. + let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + workspace_0.update_in(cx, |workspace, window, cx| { + let pane_focus = workspace.active_pane().read(cx).focus_handle(cx); + assert!( + pane_focus.contains_focused(window, cx), + "Confirming a project header should focus the workspace center pane" + ); + }); } #[gpui::test] @@ -3045,9 +3078,9 @@ mod tests { ); // Confirm on the historical (non-live) thread at index 1. - // Before the fix, workspace_index was Option and historical - // threads had None, so activate_thread early-returned without - // switching the workspace. + // Before a previous fix, the workspace field was Option and + // historical threads had None, so activate_thread early-returned + // without switching the workspace. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.selection = Some(1); sidebar.confirm(&Confirm, window, cx); @@ -3181,4 +3214,235 @@ mod tests { vec!["v [my-project]", " Friendly Greeting with AI *"] ); } + + #[gpui::test] + async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Save a thread so it appears in the list. + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, connection_a, cx); + send_message(&panel_a, cx); + let session_id_a = active_session_id(&panel_a, cx); + save_thread_to_store(&session_id_a, &path_list_a, cx).await; + + // Add a second workspace with its own agent panel. + let fs = cx.update(|_, cx| ::global(cx)); + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let panel_b = add_agent_panel(&workspace_b, &project_b, cx); + cx.run_until_parked(); + + let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + + // ── 1. Initial state: no focused thread ────────────────────────────── + // Workspace B is active (just added), so its header is the active entry. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread, None, + "Initially no thread should be focused" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), + "Active entry should be the active workspace header" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + acp_thread::AgentSessionInfo { + session_id: session_id_a.clone(), + cwd: None, + title: Some("Test".into()), + updated_at: None, + meta: None, + }, + &workspace_a, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "After clicking a thread, it should be the focused thread" + ); + let active_entry = sidebar.active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_a), + "Active entry should be the clicked thread" + ); + }); + + workspace_a.read_with(cx, |workspace, cx| { + assert!( + workspace.panel::(cx).is_some(), + "Agent panel should exist" + ); + let dock = workspace.right_dock().read(cx); + assert!( + dock.is_open(), + "Clicking a thread should open the agent panel dock" + ); + }); + + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Thread B".into()), + )]); + open_thread_with_connection(&panel_b, connection_b, cx); + send_message(&panel_b, cx); + let session_id_b = active_session_id(&panel_b, cx); + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + save_thread_to_store(&session_id_b, &path_list_b, cx).await; + cx.run_until_parked(); + + // Workspace A is currently active. Click a thread in workspace B, + // which also triggers a workspace switch. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + acp_thread::AgentSessionInfo { + session_id: session_id_b.clone(), + cwd: None, + title: Some("Thread B".into()), + updated_at: None, + meta: None, + }, + &workspace_b, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b), + "Clicking a thread in another workspace should focus that thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b), + "Active entry should be the cross-workspace thread" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_next_workspace(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread, None, + "External workspace switch should clear focused_thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), + "Active entry should be the workspace header after external switch" + ); + }); + + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("New thread".into()), + )]); + open_thread_with_connection(&panel_b, connection_b2, cx); + send_message(&panel_b, cx); + let session_id_b2 = active_session_id(&panel_b, cx); + save_thread_to_store(&session_id_b2, &path_list_b, cx).await; + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Opening a thread externally should set focused_thread" + ); + }); + + workspace_b.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Defocusing the sidebar should not clear focused_thread" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_workspace(&workspace_b, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread, None, + "Clicking a workspace header should clear focused_thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), + "Active entry should be the workspace header" + ); + }); + + // ── 8. Focusing the agent panel thread restores focused_thread ──── + // Workspace B still has session_id_b2 loaded in the agent panel. + // Clicking into the thread (simulated by focusing its view) should + // set focused_thread via the ThreadFocused event. + panel_b.update_in(cx, |panel, window, cx| { + if let Some(thread_view) = panel.active_connection_view() { + thread_view.read(cx).focus_handle(cx).focus(window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Focusing the agent panel thread should set focused_thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b2), + "Active entry should be the focused thread" + ); + }); + } } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index cd77f4fe30461b5f726c3bcd2f5f78b561e4d415..3f5981178fe118f41196538e1a22960bd55644d0 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -35,6 +35,12 @@ actions!( ] ); +pub enum MultiWorkspaceEvent { + ActiveWorkspaceChanged, + WorkspaceAdded(Entity), + WorkspaceRemoved(EntityId), +} + pub enum SidebarEvent { Open, Close, @@ -109,6 +115,8 @@ pub struct MultiWorkspace { _subscriptions: Vec, } +impl EventEmitter for MultiWorkspace {} + impl MultiWorkspace { pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> Self { let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| { @@ -304,6 +312,7 @@ impl MultiWorkspace { if !self.multi_workspace_enabled(cx) { self.workspaces[0] = workspace; self.active_workspace_index = 0; + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); cx.notify(); return; } @@ -321,7 +330,11 @@ impl MultiWorkspace { cx: &mut Context, ) -> usize { let index = self.add_workspace(workspace, cx); + let changed = self.active_workspace_index != index; self.active_workspace_index = index; + if changed { + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + } cx.notify(); index } @@ -338,7 +351,8 @@ impl MultiWorkspace { }); } Self::subscribe_to_workspace(&workspace, cx); - self.workspaces.push(workspace); + self.workspaces.push(workspace.clone()); + cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); cx.notify(); self.workspaces.len() - 1 } @@ -349,9 +363,13 @@ impl MultiWorkspace { index < self.workspaces.len(), "workspace index out of bounds" ); + let changed = self.active_workspace_index != index; self.active_workspace_index = index; self.serialize(cx); self.focus_active_workspace(window, cx); + if changed { + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + } cx.notify(); } @@ -406,7 +424,7 @@ impl MultiWorkspace { } } - fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) { + pub fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) { // If a dock panel is zoomed, focus it instead of the center pane. // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal // which closes the zoomed dock. @@ -633,6 +651,10 @@ impl MultiWorkspace { self.serialize(cx); self.focus_active_workspace(window, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved( + removed_workspace.entity_id(), + )); + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); cx.notify(); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index aba2fc9d98ed6e2178a925029ae7e040004cb102..b94a7b1091c664502fc7dcad0f753b71951ec423 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -27,9 +27,9 @@ mod workspace_settings; pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ - DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, - NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, - ToggleWorkspaceSidebar, + DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, + NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, + SidebarHandle, ToggleWorkspaceSidebar, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -1230,6 +1230,7 @@ pub enum Event { ZoomChanged, ModalOpened, Activate, + PanelAdded(AnyView), } #[derive(Debug, Clone)] @@ -2129,10 +2130,13 @@ impl Workspace { let dock_position = panel.position(window, cx); let dock = self.dock_at_position(dock_position); + let any_panel = panel.to_any(); dock.update(cx, |dock, cx| { dock.add_panel(panel, self.weak_self.clone(), window, cx) }); + + cx.emit(Event::PanelAdded(any_panel)); } pub fn remove_panel( diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c1e00b817abc8817cc81dc528c66901011f134aa..af7c6df1f83c6621715fbbab3f665f1fdbc18c65 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -371,15 +371,12 @@ pub fn initialize_workspace( }) .detach(); - cx.observe_new(|multi_workspace: &mut MultiWorkspace, window, cx| { + cx.observe_new(|_multi_workspace: &mut MultiWorkspace, window, cx| { let Some(window) = window else { return; }; - let multi_workspace_handle = cx.entity(); - let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); - multi_workspace.register_sidebar(sidebar, window, cx); - let multi_workspace_handle = multi_workspace_handle.downgrade(); + let multi_workspace_handle = cx.entity().downgrade(); window.on_window_should_close(cx, move |window, cx| { multi_workspace_handle .update(cx, |multi_workspace, cx| { @@ -389,6 +386,20 @@ pub fn initialize_workspace( }) .unwrap_or(true) }); + + let window_handle = window.window_handle(); + let multi_workspace_handle = cx.entity(); + cx.defer(move |cx| { + window_handle + .update(cx, |_, window, cx| { + let sidebar = + cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); + multi_workspace_handle.update(cx, |multi_workspace, cx| { + multi_workspace.register_sidebar(sidebar, window, cx); + }); + }) + .ok(); + }); }) .detach();