From 6fb9680bf63bf2b991f36266bb56f1001c1e33a3 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 13 Mar 2026 13:16:59 +0100 Subject: [PATCH] agent_ui: Wire up archive entry loading (#51475) Release Notes: - N/A --------- Co-authored-by: cameron Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/agent_panel.rs | 162 ++++---- crates/agent_ui/src/sidebar.rs | 420 +++++++++++++++++--- crates/agent_ui/src/thread_history_view.rs | 18 +- crates/agent_ui/src/threads_archive_view.rs | 11 +- crates/agent_ui/src/ui/mention_crease.rs | 13 +- 5 files changed, 492 insertions(+), 132 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 23dc1dfcbc086f4b145bb5372929d9aa32f30fc5..e69b6a9f164a07d17c01057ea8a57c287ab6f938 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -888,7 +888,7 @@ pub struct AgentPanel { zoomed: bool, pending_serialization: Option>>, onboarding: Entity, - selected_agent: AgentType, + selected_agent_type: AgentType, start_thread_in: StartThreadIn, worktree_creation_status: Option, _thread_view_subscription: Option, @@ -908,7 +908,7 @@ impl AgentPanel { }; let width = self.width; - let selected_agent = self.selected_agent.clone(); + let selected_agent_type = self.selected_agent_type.clone(); let start_thread_in = Some(self.start_thread_in); let last_active_thread = self.active_agent_thread(cx).map(|thread| { @@ -916,7 +916,7 @@ impl AgentPanel { let title = thread.title(); SerializedActiveThread { session_id: thread.session_id().0.to_string(), - agent_type: self.selected_agent.clone(), + agent_type: self.selected_agent_type.clone(), title: if title.as_ref() != DEFAULT_THREAD_TITLE { Some(title.to_string()) } else { @@ -931,7 +931,7 @@ impl AgentPanel { workspace_id, SerializedAgentPanel { width, - selected_agent: Some(selected_agent), + selected_agent: Some(selected_agent_type), last_active_thread, start_thread_in, }, @@ -1017,7 +1017,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); if let Some(selected_agent) = serialized_panel.selected_agent.clone() { - panel.selected_agent = selected_agent; + panel.selected_agent_type = selected_agent; } if let Some(start_thread_in) = serialized_panel.start_thread_in { let is_worktree_flag_enabled = @@ -1045,8 +1045,18 @@ impl AgentPanel { if let Some(thread_info) = last_active_thread { let agent_type = thread_info.agent_type.clone(); panel.update(cx, |panel, cx| { - panel.selected_agent = agent_type; - panel.load_agent_thread_inner(thread_info.session_id.into(), thread_info.cwd, thread_info.title.map(SharedString::from), false, window, cx); + panel.selected_agent_type = agent_type; + if let Some(agent) = panel.selected_agent() { + panel.load_agent_thread( + agent, + thread_info.session_id.into(), + thread_info.cwd, + thread_info.title.map(SharedString::from), + false, + window, + cx, + ); + } }); } panel @@ -1214,7 +1224,7 @@ impl AgentPanel { onboarding, text_thread_history, thread_store, - selected_agent: AgentType::default(), + selected_agent_type: AgentType::default(), start_thread_in: StartThreadIn::default(), worktree_creation_status: None, _thread_view_subscription: None, @@ -1403,8 +1413,8 @@ impl AgentPanel { editor }); - if self.selected_agent != AgentType::TextThread { - self.selected_agent = AgentType::TextThread; + if self.selected_agent_type != AgentType::TextThread { + self.selected_agent_type = AgentType::TextThread; self.serialize(cx); } @@ -1464,7 +1474,7 @@ impl AgentPanel { .detach(); let server = agent.server(fs, thread_store); - self.create_external_thread( + self.create_agent_thread( server, resume_session_id, cwd, @@ -1497,7 +1507,7 @@ impl AgentPanel { let server = ext_agent.server(fs, thread_store); this.update_in(cx, |agent_panel, window, cx| { - agent_panel.create_external_thread( + agent_panel.create_agent_thread( server, resume_session_id, cwd, @@ -1558,7 +1568,7 @@ impl AgentPanel { } fn has_history_for_selected_agent(&self, cx: &App) -> bool { - match &self.selected_agent { + match &self.selected_agent_type { AgentType::TextThread | AgentType::NativeAgent => true, AgentType::Custom { name } => { let agent = Agent::Custom { name: name.clone() }; @@ -1575,7 +1585,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) -> Option { - match &self.selected_agent { + match &self.selected_agent_type { AgentType::TextThread => Some(History::TextThreads), AgentType::NativeAgent => { let history = self @@ -1587,7 +1597,7 @@ impl AgentPanel { .clone(); Some(History::AgentThreads { - view: self.create_thread_history_view(history, window, cx), + view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx), }) } AgentType::Custom { name } => { @@ -1601,7 +1611,7 @@ impl AgentPanel { .clone(); if history.read(cx).has_session_list() { Some(History::AgentThreads { - view: self.create_thread_history_view(history, window, cx), + view: self.create_thread_history_view(agent, history, window, cx), }) } else { None @@ -1612,22 +1622,29 @@ impl AgentPanel { fn create_thread_history_view( &self, + agent: Agent, history: Entity, window: &mut Window, cx: &mut Context, ) -> Entity { let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx)); - cx.subscribe_in(&view, window, |this, _, event, window, cx| match event { - ThreadHistoryViewEvent::Open(thread) => { - this.load_agent_thread( - thread.session_id.clone(), - thread.cwd.clone(), - thread.title.clone(), - window, - cx, - ); - } - }) + cx.subscribe_in( + &view, + window, + move |this, _, event, window, cx| match event { + ThreadHistoryViewEvent::Open(thread) => { + this.load_agent_thread( + agent.clone(), + thread.session_id.clone(), + thread.cwd.clone(), + thread.title.clone(), + true, + window, + cx, + ); + } + }, + ) .detach(); view } @@ -1691,8 +1708,8 @@ impl AgentPanel { ) }); - if self.selected_agent != AgentType::TextThread { - self.selected_agent = AgentType::TextThread; + if self.selected_agent_type != AgentType::TextThread { + self.selected_agent_type = AgentType::TextThread; self.serialize(cx); } @@ -2266,13 +2283,17 @@ impl AgentPanel { let entry = entry.clone(); panel .update(cx, move |this, cx| { - this.load_agent_thread( - entry.session_id.clone(), - entry.cwd.clone(), - entry.title.clone(), - window, - cx, - ); + if let Some(agent) = this.selected_agent() { + this.load_agent_thread( + agent, + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + true, + window, + cx, + ); + } }) .ok(); } @@ -2322,10 +2343,6 @@ impl AgentPanel { menu.separator() } - pub fn selected_agent(&self) -> AgentType { - self.selected_agent.clone() - } - fn subscribe_to_active_thread_view( server_view: &Entity, window: &mut Window, @@ -2396,8 +2413,8 @@ impl AgentPanel { } } - fn selected_external_agent(&self) -> Option { - match &self.selected_agent { + pub(crate) fn selected_agent(&self) -> Option { + match &self.selected_agent_type { AgentType::NativeAgent => Some(Agent::NativeAgent), AgentType::Custom { name } => Some(Agent::Custom { name: name.clone() }), AgentType::TextThread => None, @@ -2493,17 +2510,7 @@ impl AgentPanel { pub fn load_agent_thread( &mut self, - session_id: acp::SessionId, - cwd: Option, - title: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.load_agent_thread_inner(session_id, cwd, title, true, window, cx); - } - - fn load_agent_thread_inner( - &mut self, + agent: Agent, session_id: acp::SessionId, cwd: Option, title: Option, @@ -2541,9 +2548,6 @@ impl AgentPanel { } } - let Some(agent) = self.selected_external_agent() else { - return; - }; self.external_thread( Some(agent), Some(session_id), @@ -2556,7 +2560,7 @@ impl AgentPanel { ); } - pub(crate) fn create_external_thread( + pub(crate) fn create_agent_thread( &mut self, server: Rc, resume_session_id: Option, @@ -2571,8 +2575,8 @@ impl AgentPanel { cx: &mut Context, ) { let selected_agent = AgentType::from(ext_agent.clone()); - if self.selected_agent != selected_agent { - self.selected_agent = selected_agent; + if self.selected_agent_type != selected_agent { + self.selected_agent_type = selected_agent; self.serialize(cx); } let thread_store = server @@ -2825,8 +2829,8 @@ impl AgentPanel { ) { self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message)); if matches!(self.active_view, ActiveView::Uninitialized) { - let selected_agent = self.selected_agent.clone(); - self.new_agent_thread(selected_agent, window, cx); + let selected_agent_type = self.selected_agent_type.clone(); + self.new_agent_thread(selected_agent_type, window, cx); } cx.notify(); } @@ -3218,8 +3222,8 @@ impl Panel for AgentPanel { Some(WorktreeCreationStatus::Creating) ) { - let selected_agent = self.selected_agent.clone(); - self.new_agent_thread_inner(selected_agent, false, window, cx); + let selected_agent_type = self.selected_agent_type.clone(); + self.new_agent_thread_inner(selected_agent_type, false, window, cx); } } @@ -3871,16 +3875,16 @@ impl AgentPanel { let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; let (selected_agent_custom_icon, selected_agent_label) = - if let AgentType::Custom { name, .. } = &self.selected_agent { + if let AgentType::Custom { name, .. } = &self.selected_agent_type { let store = agent_server_store.read(cx); let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); let label = store .agent_display_name(&ExternalAgentServerName(name.clone())) - .unwrap_or_else(|| self.selected_agent.label()); + .unwrap_or_else(|| self.selected_agent_type.label()); (icon, label) } else { - (None, self.selected_agent.label()) + (None, self.selected_agent_type.label()) }; let active_thread = match &self.active_view { @@ -3894,7 +3898,7 @@ impl AgentPanel { let new_thread_menu_builder: Rc< dyn Fn(&mut Window, &mut App) -> Option>, > = { - let selected_agent = self.selected_agent.clone(); + let selected_agent = self.selected_agent_type.clone(); let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type; let workspace = self.workspace.clone(); @@ -4210,7 +4214,7 @@ impl AgentPanel { let has_custom_icon = selected_agent_custom_icon.is_some(); let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone(); - let selected_agent_builtin_icon = self.selected_agent.icon(); + let selected_agent_builtin_icon = self.selected_agent_type.icon(); let selected_agent_label_for_tooltip = selected_agent_label.clone(); let selected_agent = div() @@ -4220,7 +4224,7 @@ impl AgentPanel { .child(Icon::from_external_svg(icon_path).color(Color::Muted)) }) .when(!has_custom_icon, |this| { - this.when_some(self.selected_agent.icon(), |this, icon| { + this.when_some(self.selected_agent_type.icon(), |this, icon| { this.px_1().child(Icon::new(icon).color(Color::Muted)) }) }) @@ -5230,7 +5234,7 @@ impl AgentPanel { name: server.name(), }; - self.create_external_thread( + self.create_agent_thread( server, None, None, None, None, workspace, project, ext_agent, true, window, cx, ); } @@ -5378,7 +5382,7 @@ mod tests { ); }); - let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone()); + let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent_type.clone()); // --- Set up workspace B: ClaudeCode, width=400, no active thread --- let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { @@ -5388,7 +5392,7 @@ mod tests { panel_b.update(cx, |panel, _cx| { panel.width = Some(px(400.0)); - panel.selected_agent = AgentType::Custom { + panel.selected_agent_type = AgentType::Custom { name: "claude-acp".into(), }; }); @@ -5421,7 +5425,7 @@ mod tests { "workspace A width should be restored" ); assert_eq!( - panel.selected_agent, agent_type_a, + panel.selected_agent_type, agent_type_a, "workspace A agent type should be restored" ); assert!( @@ -5438,7 +5442,7 @@ mod tests { "workspace B width should be restored" ); assert_eq!( - panel.selected_agent, + panel.selected_agent_type, AgentType::Custom { name: "claude-acp".into() }, @@ -5922,7 +5926,15 @@ mod tests { // Load thread A back via load_agent_thread — should promote from background. panel.update_in(&mut cx, |panel, window, cx| { - panel.load_agent_thread(session_id_a.clone(), None, None, window, cx); + panel.load_agent_thread( + panel.selected_agent().expect("selected agent must be set"), + session_id_a.clone(), + None, + None, + true, + window, + cx, + ); }); // Thread A should now be the active view, promoted from background. diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 6dc684b3d30737dbce1b7d1c9c706341cf4ef11f..0bc0968ea44c25ec9cfd3d68d8600814f922fc12 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,5 +1,5 @@ use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; -use crate::{AgentPanel, AgentPanelEvent, NewThread}; +use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::ThreadStore; @@ -107,6 +107,7 @@ enum ThreadEntryWorkspace { #[derive(Clone)] struct ThreadEntry { + agent: Agent, session_info: acp_thread::AgentSessionInfo, icon: IconName, icon_from_external_svg: Option, @@ -192,7 +193,7 @@ fn root_repository_snapshots( workspace: &Entity, cx: &App, ) -> Vec { - let (path_list, _) = workspace_path_list_and_label(workspace, cx); + let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); project .repositories(cx) @@ -208,34 +209,23 @@ fn root_repository_snapshots( .collect() } -fn workspace_path_list_and_label( - workspace: &Entity, - cx: &App, -) -> (PathList, SharedString) { - let workspace_ref = workspace.read(cx); - let mut paths = Vec::new(); - let mut names = Vec::new(); - - for worktree in workspace_ref.worktrees(cx) { - let worktree_ref = worktree.read(cx); - if !worktree_ref.is_visible() { - continue; - } - let abs_path = worktree_ref.abs_path(); - paths.push(abs_path.to_path_buf()); +fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { + PathList::new(&workspace.read(cx).root_paths(cx)) +} + +fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { + let mut names = Vec::with_capacity(path_list.paths().len()); + for abs_path in path_list.paths() { if let Some(name) = abs_path.file_name() { names.push(name.to_string_lossy().to_string()); } } - - let label: SharedString = if names.is_empty() { + if names.is_empty() { // TODO: Can we do something better in this case? "Empty Workspace".into() } else { names.join(", ").into() - }; - - (PathList::new(&paths), label) + } } pub struct Sidebar { @@ -578,7 +568,8 @@ impl Sidebar { continue; } - let (path_list, label) = workspace_path_list_and_label(workspace, cx); + let path_list = workspace_path_list(workspace, cx); + let label = workspace_label_from_path_list(&path_list); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); @@ -592,6 +583,7 @@ impl Sidebar { for meta in thread_store.read(cx).threads_for_paths(&path_list) { seen_session_ids.insert(meta.id.clone()); threads.push(ThreadEntry { + agent: Agent::NativeAgent, session_info: meta.into(), icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -644,6 +636,7 @@ impl Sidebar { continue; } threads.push(ThreadEntry { + agent: Agent::NativeAgent, session_info: meta.into(), icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -1222,7 +1215,7 @@ impl Sidebar { // contains other folders. let mut to_remove: Vec> = Vec::new(); for workspace in &workspaces { - let (path_list, _) = workspace_path_list_and_label(workspace, cx); + let path_list = workspace_path_list(workspace, cx); if path_list.paths().len() != 1 { continue; } @@ -1370,10 +1363,17 @@ impl Sidebar { match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => { let workspace = workspace.clone(); - self.activate_thread(session_info, &workspace, window, cx); + self.activate_thread( + thread.agent.clone(), + session_info, + &workspace, + window, + cx, + ); } ThreadEntryWorkspace::Closed(path_list) => { self.open_workspace_and_activate_thread( + thread.agent.clone(), session_info, path_list.clone(), window, @@ -1405,6 +1405,7 @@ impl Sidebar { fn activate_thread( &mut self, + agent: Agent, session_info: acp_thread::AgentSessionInfo, workspace: &Entity, window: &mut Window, @@ -1425,18 +1426,23 @@ impl Sidebar { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.load_agent_thread( + agent, session_info.session_id, session_info.cwd, session_info.title, + true, window, cx, ); }); } + + self.update_entries(cx); } fn open_workspace_and_activate_thread( &mut self, + agent: Agent, session_info: acp_thread::AgentSessionInfo, path_list: PathList, window: &mut Window, @@ -1454,13 +1460,69 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = open_task.await?; this.update_in(cx, |this, window, cx| { - this.activate_thread(session_info, &workspace, window, cx); + this.activate_thread(agent, session_info, &workspace, window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } + fn find_open_workspace_for_path_list( + &self, + path_list: &PathList, + cx: &App, + ) -> Option> { + let multi_workspace = self.multi_workspace.upgrade()?; + multi_workspace + .read(cx) + .workspaces() + .iter() + .find(|workspace| workspace_path_list(workspace, cx).paths() == path_list.paths()) + .cloned() + } + + fn activate_archived_thread( + &mut self, + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + window: &mut Window, + cx: &mut Context, + ) { + let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| { + thread_store + .read(cx) + .thread_from_session_id(&session_info.session_id) + .map(|thread| thread.folder_paths.clone()) + }); + let path_list = saved_path_list.or_else(|| { + // we don't have saved metadata, so create path list based on the cwd + session_info + .cwd + .as_ref() + .map(|cwd| PathList::new(&[cwd.to_path_buf()])) + }); + + if let Some(path_list) = path_list { + if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) { + self.activate_thread(agent, session_info, &workspace, window, cx); + } else { + self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx); + } + return; + } + + let active_workspace = self.multi_workspace.upgrade().and_then(|w| { + w.read(cx) + .workspaces() + .get(w.read(cx).active_workspace_index()) + .cloned() + }); + + if let Some(workspace) = active_workspace { + self.activate_thread(agent, session_info, &workspace, window, cx); + } + } + fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, @@ -1589,22 +1651,32 @@ impl Sidebar { .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) .docked_right(docked_right) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - match &thread_workspace { - ThreadEntryWorkspace::Open(workspace) => { - this.activate_thread(session_info.clone(), workspace, window, cx); - } - ThreadEntryWorkspace::Closed(path_list) => { - this.open_workspace_and_activate_thread( - session_info.clone(), - path_list.clone(), - window, - cx, - ); + .on_click({ + let agent = thread.agent.clone(); + cx.listener(move |this, _, window, cx| { + this.selection = None; + match &thread_workspace { + ThreadEntryWorkspace::Open(workspace) => { + this.activate_thread( + agent.clone(), + session_info.clone(), + workspace, + window, + cx, + ); + } + ThreadEntryWorkspace::Closed(path_list) => { + this.open_workspace_and_activate_thread( + agent.clone(), + session_info.clone(), + path_list.clone(), + window, + cx, + ); + } } - } - })) + }) + }) .into_any_element() } @@ -1852,8 +1924,12 @@ impl Sidebar { ThreadsArchiveViewEvent::Close => { this.show_thread_list(window, cx); } - ThreadsArchiveViewEvent::OpenThread(_session_info) => { - //TODO: Actually open thread once we support it + ThreadsArchiveViewEvent::OpenThread { + agent, + session_info, + } => { + this.show_thread_list(window, cx); + this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx); } }, ); @@ -2506,6 +2582,7 @@ mod tests { }, // Thread with default (Completed) status, not active ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-1")), cwd: None, @@ -2527,6 +2604,7 @@ mod tests { }), // Active thread with Running status ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-2")), cwd: None, @@ -2548,6 +2626,7 @@ mod tests { }), // Active thread with Error status ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-3")), cwd: None, @@ -2569,6 +2648,7 @@ mod tests { }), // Thread with WaitingForConfirmation status, not active ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-4")), cwd: None, @@ -2590,6 +2670,7 @@ mod tests { }), // Background thread that completed (should show notification) ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { session_id: acp::SessionId::new(Arc::from("t-5")), cwd: None, @@ -3940,6 +4021,7 @@ mod tests { // ── 2. Click thread in workspace A via sidebar ─────────────────────── sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_thread( + Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_a.clone(), cwd: None, @@ -4007,6 +4089,7 @@ mod tests { // which also triggers a workspace switch. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.activate_thread( + Agent::NativeAgent, acp_thread::AgentSessionInfo { session_id: session_id_b.clone(), cwd: None, @@ -4469,9 +4552,8 @@ mod tests { mw.workspaces()[1].clone() }); - let (new_path_list, _) = new_workspace.read_with(cx, |_, cx| { - workspace_path_list_and_label(&new_workspace, cx) - }); + let new_path_list = + new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); assert_eq!( new_path_list, PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), @@ -4593,4 +4675,250 @@ mod tests { "clicking an absorbed worktree thread should activate the worktree workspace" ); } + + #[gpui::test] + async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( + cx: &mut TestAppContext, + ) { + // Thread has saved metadata in ThreadStore. A matching workspace is + // already open. Expected: activates the matching workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-1")); + save_thread_to_store(&session_id, &path_list_b, cx).await; + + // Ensure workspace A is active. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // Call activate_archived_thread – should resolve saved paths and + // switch to the workspace for project-b. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + cwd: Some("/project-b".into()), + title: Some("Archived Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the saved path_list" + ); + } + + #[gpui::test] + async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( + cx: &mut TestAppContext, + ) { + // Thread has no saved metadata but session_info has cwd. A matching + // workspace is open. Expected: uses cwd to find and activate it. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start with workspace A active. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // No thread saved to the store – cwd is the only path hint. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("unknown-session")), + cwd: Some(std::path::PathBuf::from("/project-b")), + title: Some("CWD Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the cwd" + ); + } + + #[gpui::test] + async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( + cx: &mut TestAppContext, + ) { + // Thread has no saved metadata and no cwd. Expected: falls back to + // the currently active workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Activate workspace B (index 1) to make it the active one. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); + + // No saved thread, no cwd – should fall back to the active workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("no-context-session")), + cwd: None, + title: Some("Contextless Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have stayed on the active workspace when no path info is available" + ); + } + + #[gpui::test] + async fn test_activate_archived_thread_saved_paths_opens_new_workspace( + cx: &mut TestAppContext, + ) { + // Thread has saved metadata pointing to a path with no open workspace. + // Expected: opens a new workspace for that path. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b – which has no + // open workspace. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); + save_thread_to_store(&session_id, &path_list_b, cx).await; + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + "should start with one workspace" + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + cwd: None, + title: Some("New WS Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 2, + "should have opened a second workspace for the archived thread's saved paths" + ); + } } diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs index 4e43748911ba0559485e7a4d991e5dc9d2d4c524..092169efbf57f2947f2532e4a599e7b4935dc539 100644 --- a/crates/agent_ui/src/thread_history_view.rs +++ b/crates/agent_ui/src/thread_history_view.rs @@ -751,13 +751,17 @@ impl RenderOnce for HistoryEntryElement { { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { - panel.load_agent_thread( - entry.session_id.clone(), - entry.cwd.clone(), - entry.title.clone(), - window, - cx, - ); + if let Some(agent) = panel.selected_agent() { + panel.load_agent_thread( + agent, + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + true, + window, + cx, + ); + } }); } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 3d7dba591dfa60f7408f9710561863791bcd802b..e1fd44b4d81280037404fa3f2415b39bdc2aade7 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -89,7 +89,10 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option> { pub enum ThreadsArchiveViewEvent { Close, - OpenThread(AgentSessionInfo), + OpenThread { + agent: Agent, + session_info: AgentSessionInfo, + }, } impl EventEmitter for ThreadsArchiveView {} @@ -263,7 +266,10 @@ impl ThreadsArchiveView { ) { self.selection = None; self.reset_filter_editor_text(window, cx); - cx.emit(ThreadsArchiveViewEvent::OpenThread(session_info)); + cx.emit(ThreadsArchiveViewEvent::OpenThread { + agent: self.selected_agent.clone(), + session_info, + }); } fn is_selectable_item(&self, ix: usize) -> bool { @@ -413,7 +419,6 @@ impl ThreadsArchiveView { ListItem::new(id) .toggle_state(is_selected) - .disabled(true) .child( h_flex() .min_w_0() diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 0f0b8ecc1d7d66a6025bcfed772c7ead7061fe20..b70b77e6ca603aba8fd55706918ffb3543e2a734 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -13,6 +13,8 @@ use theme::ThemeSettings; use ui::{ButtonLike, TintColor, Tooltip, prelude::*}; use workspace::{OpenOptions, Workspace}; +use crate::Agent; + #[derive(IntoElement)] pub struct MentionCrease { id: ElementId, @@ -275,8 +277,17 @@ fn open_thread( return; }; + // Right now we only support loading threads in the native agent panel.update(cx, |panel, cx| { - panel.load_agent_thread(id, None, Some(name.into()), window, cx) + panel.load_agent_thread( + Agent::NativeAgent, + id, + None, + Some(name.into()), + true, + window, + cx, + ) }); }