From 4cb10dfdbdff2fb0aef98bf719f907a0ae470da1 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:46:53 -0400 Subject: [PATCH] sidebar: Add property tests (#52540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context ## How to Review ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A Co-authored-by: Mikayla Maki --- crates/sidebar/src/project_group_builder.rs | 16 +- crates/sidebar/src/sidebar.rs | 4279 +---------------- crates/sidebar/src/sidebar_tests.rs | 4664 +++++++++++++++++++ 3 files changed, 4705 insertions(+), 4254 deletions(-) create mode 100644 crates/sidebar/src/sidebar_tests.rs diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index 2443a719639c2dc4b83b26d1213db214e13d70f7..8b4a0862dcd269310eb571f4db6703ed0e508fef 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -73,6 +73,16 @@ impl ProjectGroup { .first() .expect("groups always have at least one workspace") } + + pub fn main_workspace(&self, cx: &App) -> &Entity { + self.workspaces + .iter() + .find(|ws| { + !crate::root_repository_snapshots(ws, cx) + .any(|snapshot| snapshot.is_linked_worktree()) + }) + .unwrap_or_else(|| self.first_workspace()) + } } pub struct ProjectGroupBuilder { @@ -91,7 +101,6 @@ impl ProjectGroupBuilder { pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self { let mut builder = Self::new(); - // First pass: collect all directory mappings from every workspace // so we know how to canonicalize any path (including linked // worktree paths discovered by the main repo's workspace). @@ -148,9 +157,8 @@ impl ProjectGroupBuilder { workspace: &Entity, cx: &App, ) -> ProjectGroupName { - let paths: Vec<_> = workspace - .read(cx) - .root_paths(cx) + let root_paths = workspace.read(cx).root_paths(cx); + let paths: Vec<_> = root_paths .iter() .map(|p| self.canonicalize_path(p).to_path_buf()) .collect(); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index a5c26a0b3c56c3ad3483d5f445daa87d3cd8ae6f..420942da64acec08f9d7acf2d618764e36066aa0 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -50,6 +50,9 @@ use crate::project_group_builder::ProjectGroupBuilder; mod project_group_builder; +#[cfg(test)] +mod sidebar_tests; + gpui::actions!( agents_sidebar, [ @@ -167,6 +170,28 @@ enum ListEntry { }, } +#[cfg(test)] +impl ListEntry { + fn workspace(&self) -> Option> { + match self { + ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()), + ListEntry::Thread(thread_entry) => match &thread_entry.workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), + ThreadEntryWorkspace::Closed(_) => None, + }, + ListEntry::ViewMore { .. } => None, + ListEntry::NewThread { workspace, .. } => Some(workspace.clone()), + } + } + + fn session_id(&self) -> Option<&acp::SessionId> { + match self { + ListEntry::Thread(thread_entry) => Some(&thread_entry.session_info.session_id), + _ => None, + } + } +} + impl From for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(thread) @@ -418,7 +443,7 @@ impl Sidebar { cx.subscribe_in( &git_store, window, - |this, _, event: &project::git_store::GitStoreEvent, window, cx| { + |this, _, event: &project::git_store::GitStoreEvent, _window, cx| { if matches!( event, project::git_store::GitStoreEvent::RepositoryUpdated( @@ -427,7 +452,6 @@ impl Sidebar { _, ) ) { - this.prune_stale_worktree_workspaces(window, cx); this.update_entries(cx); } }, @@ -698,14 +722,12 @@ impl Sidebar { .is_some_and(|active| group.workspaces.contains(active)); // Pick a representative workspace for the group: prefer the active - // workspace if it belongs to this group, otherwise use the first. - // - // This is the workspace that will be activated by the project group - // header. + // workspace if it belongs to this group, otherwise use the main + // repo workspace (not a linked worktree). let representative_workspace = active_workspace .as_ref() .filter(|_| is_active) - .unwrap_or_else(|| group.first_workspace()); + .unwrap_or_else(|| group.main_workspace(cx)); // Collect live thread infos from all workspaces in this group. let live_infos: Vec<_> = group @@ -1594,72 +1616,6 @@ impl Sidebar { Some(element) } - fn prune_stale_worktree_workspaces(&mut self, 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(); - - // Collect all worktree paths that are currently listed by any main - // repo open in any workspace. - let mut known_worktree_paths: HashSet = HashSet::new(); - for workspace in &workspaces { - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_linked_worktree() { - continue; - } - for git_worktree in snapshot.linked_worktrees() { - known_worktree_paths.insert(git_worktree.path.to_path_buf()); - } - } - } - - // Find workspaces that consist of exactly one root folder which is a - // stale worktree checkout. Multi-root workspaces are never pruned — - // losing one worktree shouldn't destroy a workspace that also - // contains other folders. - let mut to_remove: Vec> = Vec::new(); - for workspace in &workspaces { - let path_list = workspace_path_list(workspace, cx); - if path_list.paths().len() != 1 { - continue; - } - let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| { - snapshot.is_linked_worktree() - && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) - }); - if should_prune { - to_remove.push(workspace.clone()); - } - } - - for workspace in &to_remove { - self.remove_workspace(workspace, window, cx); - } - } - - fn remove_workspace( - &mut self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - multi_workspace.update(cx, |multi_workspace, 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, @@ -3201,4180 +3157,3 @@ fn all_thread_infos_for_workspace( Some(threads).into_iter().flatten() } - -#[cfg(test)] -mod tests { - use super::*; - use acp_thread::StubAgentConnection; - use agent::ThreadStore; - use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; - use assistant_text_thread::TextThreadStore; - use chrono::DateTime; - use feature_flags::FeatureFlagAppExt as _; - use fs::FakeFs; - use gpui::TestAppContext; - use pretty_assertions::assert_eq; - use settings::SettingsStore; - use std::{path::PathBuf, sync::Arc}; - use util::path_list::PathList; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - } - - fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { - sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id) - }) - } - - async fn init_test_project( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn setup_sidebar( - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - 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(cx, |mw, cx| { - mw.register_sidebar(sidebar.clone(), cx); - }); - cx.run_until_parked(); - sidebar - } - - async fn save_n_test_threads( - count: u32, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - for i in 0..count { - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - format!("Thread {}", i + 1).into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - } - - async fn save_test_thread_metadata( - session_id: &acp::SessionId, - path_list: PathList, - cx: &mut TestAppContext, - ) { - save_thread_metadata( - session_id.clone(), - "Test".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list, - cx, - ) - .await; - } - - async fn save_named_thread_metadata( - session_id: &str, - title: &str, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - save_thread_metadata( - acp::SessionId::new(Arc::from(session_id)), - SharedString::from(title.to_string()), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - } - - async fn save_thread_metadata( - session_id: acp::SessionId, - title: SharedString, - updated_at: DateTime, - path_list: PathList, - cx: &mut TestAppContext, - ) { - let metadata = ThreadMetadata { - session_id, - agent_id: None, - title, - updated_at, - created_at: None, - folder_paths: path_list, - }; - cx.update(|cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) - }); - cx.run_until_parked(); - } - - fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { - let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); - if let Some(multi_workspace) = multi_workspace { - multi_workspace.update_in(cx, |mw, window, cx| { - if !mw.sidebar_open() { - mw.toggle_sidebar(window, cx); - } - }); - } - cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - } - - fn visible_entries_as_strings( - sidebar: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Vec { - sidebar.read_with(cx, |sidebar, _cx| { - sidebar - .contents - .entries - .iter() - .enumerate() - .map(|(ix, entry)| { - let selected = if sidebar.selection == Some(ix) { - " <== selected" - } else { - "" - }; - match entry { - ListEntry::ProjectHeader { - label, - path_list, - highlight_positions: _, - .. - } => { - let icon = if sidebar.collapsed_groups.contains(path_list) { - ">" - } else { - "v" - }; - format!("{} [{}]{}", icon, label, selected) - } - ListEntry::Thread(thread) => { - let title = thread - .session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or("Untitled"); - let active = if thread.is_live { " *" } else { "" }; - let status_str = match thread.status { - AgentThreadStatus::Running => " (running)", - AgentThreadStatus::Error => " (error)", - AgentThreadStatus::WaitingForConfirmation => " (waiting)", - _ => "", - }; - let notified = if sidebar - .contents - .is_thread_notified(&thread.session_info.session_id) - { - " (!)" - } else { - "" - }; - let worktree = if thread.worktrees.is_empty() { - String::new() - } else { - let mut seen = Vec::new(); - let mut chips = Vec::new(); - for wt in &thread.worktrees { - if !seen.contains(&wt.name) { - seen.push(wt.name.clone()); - chips.push(format!("{{{}}}", wt.name)); - } - } - format!(" {}", chips.join(", ")) - }; - format!( - " {}{}{}{}{}{}", - title, worktree, active, status_str, notified, selected - ) - } - ListEntry::ViewMore { - is_fully_expanded, .. - } => { - if *is_fully_expanded { - format!(" - Collapse{}", selected) - } else { - format!(" + View More{}", selected) - } - } - ListEntry::NewThread { .. } => { - format!(" [+ New Thread]{}", selected) - } - } - }) - .collect() - }) - } - - #[test] - fn test_clean_mention_links() { - // Simple mention link - assert_eq!( - Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), - "check @Button.tsx" - ); - - // Multiple mention links - assert_eq!( - Sidebar::clean_mention_links( - "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" - ), - "look at @foo.rs and @bar.rs" - ); - - // No mention links — passthrough - assert_eq!( - Sidebar::clean_mention_links("plain text with no mentions"), - "plain text with no mentions" - ); - - // Incomplete link syntax — preserved as-is - assert_eq!( - Sidebar::clean_mention_links("broken [@mention without closing"), - "broken [@mention without closing" - ); - - // Regular markdown link (no @) — not touched - assert_eq!( - Sidebar::clean_mention_links("see [docs](https://example.com)"), - "see [docs](https://example.com)" - ); - - // Empty input - assert_eq!(Sidebar::clean_mention_links(""), ""); - } - - #[gpui::test] - async fn test_entities_released_on_window_close(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); - let weak_sidebar = sidebar.downgrade(); - let weak_multi_workspace = multi_workspace.downgrade(); - - drop(sidebar); - drop(multi_workspace); - cx.update(|window, _cx| window.remove_window()); - cx.run_until_parked(); - - weak_multi_workspace.assert_released(); - weak_sidebar.assert_released(); - weak_workspace.assert_released(); - } - - #[gpui::test] - async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]"] - ); - } - - #[gpui::test] - async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix crash in project panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-2")), - "Add inline diff view".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - ] - ); - } - - #[gpui::test] - async fn test_workspace_lifecycle(cx: &mut TestAppContext) { - let project = init_test_project("/project-a", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Single workspace with a thread - let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-a1")), - "Thread A1".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - - // Add a second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1",] - ); - - // Remove the second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - } - - #[gpui::test] - async fn test_view_more_pagination(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(12, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Thread 12", - " Thread 11", - " Thread 10", - " Thread 9", - " Thread 8", - " + View More", - ] - ); - } - - #[gpui::test] - async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse - save_n_test_threads(17, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Initially shows 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus and navigate to View More, then confirm to expand by one batch - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // Now shows 10 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 12); // header + 10 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand again by one batch - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Now shows 15 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 17); // header + 15 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand one more time - should show all 17 threads with Collapse button - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // All 17 threads shown with Collapse button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 19); // header + 17 threads + Collapse - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - - // Click collapse - should go back to showing 5 threads - sidebar.update_in(cx, |s, _window, cx| { - s.expanded_groups.remove(&path_list); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Back to initial state: 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - } - - #[gpui::test] - async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Collapse - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]"] - ); - - // Expand - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - } - - #[gpui::test] - async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - 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")]); - - sidebar.update_in(cx, |s, _window, _cx| { - s.collapsed_groups.insert(collapsed_path.clone()); - s.contents - .notified_threads - .insert(acp::SessionId::new(Arc::from("t-5"))); - s.contents.entries = vec![ - // Expanded project header - ListEntry::ProjectHeader { - path_list: expanded_path.clone(), - label: "expanded-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - is_active: true, - }, - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-1")), - work_dirs: None, - title: Some("Completed thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // 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")), - work_dirs: None, - title: Some("Running thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Running, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // 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")), - work_dirs: None, - title: Some("Error thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Error, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // 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")), - work_dirs: None, - title: Some("Waiting thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::WaitingForConfirmation, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // 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")), - work_dirs: None, - title: Some("Notified thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: true, - is_title_generating: false, - highlight_positions: Vec::new(), - worktrees: Vec::new(), - diff_stats: DiffStats::default(), - }), - // View More entry - ListEntry::ViewMore { - path_list: expanded_path.clone(), - is_fully_expanded: false, - }, - // Collapsed project header - ListEntry::ProjectHeader { - path_list: collapsed_path.clone(), - label: "collapsed-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - is_active: false, - }, - ]; - - // Select the Running thread (index 2) - s.selection = Some(2); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [expanded-project]", - " Completed thread", - " Running thread * (running) <== selected", - " Error thread * (error)", - " Waiting thread (waiting)", - " Notified thread * (!)", - " + View More", - "> [collapsed-project]", - ] - ); - - // Move selection to the collapsed header - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = Some(7); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx).last().cloned(), - Some("> [collapsed-project] <== selected".to_string()), - ); - - // Clear selection - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = None; - }); - - // No entry should have the selected marker - let entries = visible_entries_as_strings(&sidebar, cx); - for entry in &entries { - assert!( - !entry.contains("<== selected"), - "unexpected selection marker in: {}", - entry - ); - } - } - - #[gpui::test] - async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Entries: [header, thread3, thread2, thread1] - // Focusing the sidebar does not set a selection; select_next/select_previous - // handle None gracefully by starting from the first or last entry. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Move down through remaining entries - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Navigate back to the end - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // Move back up - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // At the top, selection clears (focus returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // SelectLast jumps to the end - cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // SelectFirst jumps to the beginning - cx.dispatch_action(SelectFirst); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Initially no selection - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Open the sidebar so it's rendered, then focus it to trigger focus_in. - // focus_in no longer sets a default selection. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Manually set a selection, blur, then refocus — selection should be preserved - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.update(|window, _cx| { - window.blur(); - }); - cx.run_until_parked(); - - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Focus the sidebar and select the header (index 0) - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - // Confirm on project header collapses the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Confirm again expands the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(8, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show header + 5 threads + "View More" - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); - - // Confirm on "View More" to expand - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // All 8 threads should now be visible with a "Collapse" button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - } - - #[gpui::test] - async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Press right to expand - cx.dispatch_action(SelectChild); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - - // Press right again on already-expanded header moves selection down - cx.dispatch_action(SelectChild); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - } - - #[gpui::test] - async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1 <== selected",] - ); - - // Pressing left on a child collapses the parent group and selects it - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - } - - #[gpui::test] - async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { - let project = init_test_project("/empty-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // An empty project has the header and a new thread button. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [empty-project]", " [+ New Thread]"] - ); - - // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 (header) - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectNext moves to the new thread button - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectPrevious from first entry clears selection (returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // Collapse the group, which removes the thread from the list - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - // Selection should be clamped to the last valid index (0 = header) - let selection = sidebar.read_with(cx, |s, _| s.selection); - let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); - assert!( - selection.unwrap_or(0) < entry_count, - "selection {} should be within bounds (entries: {})", - selection.unwrap_or(0), - entry_count, - ); - } - - async fn init_test_project_with_agent_panel( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn add_agent_panel( - workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - workspace.update_in(cx, |workspace, window, cx| { - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - } - - fn setup_sidebar_with_agent_panel( - multi_workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> (Entity, Entity) { - let sidebar = setup_sidebar(multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - let panel = add_agent_panel(&workspace, project, cx); - (sidebar, panel) - } - - #[gpui::test] - async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Open thread A and keep it generating. - let connection = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection.clone(), cx); - send_message(&panel, cx); - - let session_id_a = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Open thread B (idle, default response) — thread A goes to background. - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id_b = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; - - cx.run_until_parked(); - - let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[1..].sort(); - assert_eq!( - entries, - vec!["v [my-project]", " Hello *", " Hello * (running)",] - ); - } - - #[gpui::test] - async fn test_background_thread_completion_triggers_notification(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")]); - - // Open thread on workspace A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel_a, connection_a.clone(), cx); - send_message(&panel_a, cx); - - let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; - - cx.update(|_, cx| { - connection_a.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Add a second workspace and activate it (making workspace A the background). - let fs = cx.update(|_, cx| ::global(cx)); - let project_b = project::Project::test(fs, [], cx).await; - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - cx.run_until_parked(); - - // Thread A is still running; no notification yet. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (running)",] - ); - - // Complete thread A's turn (transition Running → Completed). - connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); - cx.run_until_parked(); - - // The completed background thread shows a notification indicator. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (!)",] - ); - } - - fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { - sidebar.update_in(cx, |sidebar, window, cx| { - window.focus(&sidebar.filter_editor.focus_handle(cx), cx); - sidebar.filter_editor.update(cx, |editor, cx| { - editor.set_text(query, window, cx); - }); - }); - cx.run_until_parked(); - } - - #[gpui::test] - async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in project panel", 3), - ("t-2", "Add inline diff view", 2), - ("t-3", "Refactor settings module", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - " Refactor settings module", - ] - ); - - // User types "diff" in the search box — only the matching thread remains, - // with its workspace header preserved for context. - type_in_search(&sidebar, "diff", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Add inline diff view <== selected",] - ); - - // User changes query to something with no matches — list is empty. - type_in_search(&sidebar, "nonexistent", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - } - - #[gpui::test] - async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { - // Scenario: A user remembers a thread title but not the exact casing. - // Search should match case-insensitively so they can still find it. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix Crash In Project Panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // Lowercase query matches mixed-case title. - type_in_search(&sidebar, "fix crash", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - - // Uppercase query also matches the same title. - type_in_search(&sidebar, "FIX CRASH", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - } - - #[gpui::test] - async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { - // Scenario: A user searches, finds what they need, then presses Escape - // to dismiss the filter and see the full list again. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // Confirm the full list is showing. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread", " Beta thread",] - ); - - // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, cx); - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread <== selected",] - ); - - // User presses Escape — filter clears, full list is restored. - // The selection index (1) now points at the first thread entry. - cx.dispatch_action(Cancel); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Alpha thread <== selected", - " Beta thread", - ] - ); - } - - #[gpui::test] - async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { - let project_a = init_test_project("/project-a", 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); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar", - " Add tests for editor", - ] - ); - - // "sidebar" matches a thread in each workspace — both headers stay visible. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Fix bug in sidebar <== selected",] - ); - - // "typo" only matches in the second workspace — the first header disappears. - type_in_search(&sidebar, "typo", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - - // "project-a" matches the first workspace name — the header appears - // with all child threads included. - type_in_search(&sidebar, "project-a", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - } - - #[gpui::test] - async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { - let project_a = init_test_project("/alpha-project", 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); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // "alpha" matches the workspace name "alpha-project" but no thread titles. - // The workspace header should appear with all child threads included. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // "sidebar" matches thread titles in both workspaces but not workspace names. - // Both headers appear with their matching threads. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r - // doesn't match) — but does not match either workspace name or any thread. - // Actually let's test something simpler: a query that matches both a workspace - // name AND some threads in that workspace. Matching threads should still appear. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // A query that matches a workspace name AND a thread in that same workspace. - // Both the header (highlighted) and all child threads should appear. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // Now search for something that matches only a workspace name when there - // are also threads with matching titles — the non-matching workspace's - // threads should still appear if their titles match. - type_in_search(&sidebar, "alp", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - } - - #[gpui::test] - async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create 8 threads. The oldest one has a unique name and will be - // behind View More (only 5 shown by default). - for i in 0..8u32 { - let title = if i == 0 { - "Hidden gem thread".to_string() - } else { - format!("Thread {}", i + 1) - }; - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // Confirm the thread is not visible and View More is shown. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - entries.iter().any(|e| e.contains("View More")), - "should have View More button" - ); - assert!( - !entries.iter().any(|e| e.contains("Hidden gem")), - "Hidden gem should be behind View More" - ); - - // User searches for the hidden thread — it appears, and View More is gone. - type_in_search(&sidebar, "hidden gem", cx); - let filtered = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - filtered, - vec!["v [my-project]", " Hidden gem thread <== selected",] - ); - assert!( - !filtered.iter().any(|e| e.contains("View More")), - "View More should not appear when filtering" - ); - } - - #[gpui::test] - async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Important thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // User focuses the sidebar and collapses the group using keyboard: - // manually select the header, then press SelectParent to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // User types a search — the thread appears even though its group is collapsed. - type_in_search(&sidebar, "important", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]", " Important thread <== selected",] - ); - } - - #[gpui::test] - async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in panel", 3), - ("t-2", "Fix lint warnings", 2), - ("t-3", "Add new feature", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // User types "fix" — two threads match. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - - // Selection starts on the first matching thread. User presses - // SelectNext to move to the second match. - cx.dispatch_action(SelectNext); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel", - " Fix lint warnings <== selected", - ] - ); - - // User can also jump back with SelectPrevious. - cx.dispatch_action(SelectPrevious); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - } - - #[gpui::test] - async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("hist-1")), - "Historical Thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Historical Thread",] - ); - - // Switch to workspace 1 so we can verify the confirm switches back. - 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 - ); - - // Confirm on the historical (non-live) thread at index 1. - // 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); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - } - - #[gpui::test] - async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-1")), - "Thread A".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-2")), - "Thread B".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread A", " Thread B",] - ); - - // Keyboard confirm preserves selection. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - assert_eq!( - sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(1) - ); - - // Click handlers clear selection to None so no highlight lingers - // after a click regardless of focus state. The hover style provides - // visual feedback during mouse interaction instead. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = None; - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - sidebar.toggle_collapse(&path_list, window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - - // When the user tabs back into the sidebar, focus_in no longer - // restores selection — it stays None. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.focus_in(window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - } - - #[gpui::test] - async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Hi there!".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate the agent generating a title. The notification chain is: - // AcpThread::set_title emits TitleUpdated → - // ConnectionView::handle_thread_event calls cx.notify() → - // AgentPanel observer fires and emits AgentPanelEvent → - // Sidebar subscription calls update_entries / rebuild_contents. - // - // Before the fix, handle_thread_event did NOT call cx.notify() for - // TitleUpdated, so the AgentPanel observer never fired and the - // sidebar kept showing the old title. - let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); - thread.update(cx, |thread, cx| { - thread - .set_title("Friendly Greeting with AI".into(), cx) - .detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - 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_test_thread_metadata(&session_id_a, path_list_a.clone(), 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: focused thread derived from active panel ───── - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "The active panel's thread should be focused on startup" - ); - }); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_a.clone(), - work_dirs: None, - title: Some("Test".into()), - updated_at: None, - created_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" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The clicked thread should be present in the entries" - ); - }); - - 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_test_thread_metadata(&session_id_b, path_list_b.clone(), 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( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_b.clone(), - work_dirs: None, - title: Some("Thread B".into()), - updated_at: None, - created_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" - ); - assert!( - has_thread_entry(sidebar, &session_id_b), - "The cross-workspace thread should be present in the entries" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The seeded thread should be present in the entries" - ); - }); - - let connection_b2 = StubAgentConnection::new(); - connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new(DEFAULT_THREAD_TITLE.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_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; - cx.run_until_parked(); - - // Panel B is not the active workspace's panel (workspace A is - // active), so opening a thread there should not change focused_thread. - // This prevents running threads in background workspaces from causing - // the selection highlight to jump around. - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Opening a thread in a non-active panel should not change 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_a), - "Defocusing the sidebar should not change focused_thread" - ); - }); - - // Switching workspaces via the multi_workspace (simulates clicking - // a workspace header) should clear focused_thread. - multi_workspace.update_in(cx, |mw, window, cx| { - if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { - mw.activate_index(index, window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The seeded thread should be present in the entries" - ); - }); - - // ── 8. Focusing the agent panel thread keeps focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // keep focused_thread since it was already seeded on workspace switch. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_conversation_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" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The focused thread should be present in the entries" - ); - }); - } - - #[gpui::test] - async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/project-a", cx).await; - let fs = cx.update(|cx| ::global(cx)); - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Start a thread and send a message so it has history. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; - cx.run_until_parked(); - - // Verify the thread appears in the sidebar. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello *",] - ); - - // The "New Thread" button should NOT be in "active/draft" state - // because the panel has a thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "Panel has a thread with messages, so it should not be a draft" - ); - }); - - // Now add a second folder to the workspace, changing the path_list. - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - project - .update(cx, |project, cx| { - project.find_or_create_worktree("/project-b", true, cx) - }) - .await - .expect("should add worktree"); - cx.run_until_parked(); - - // The workspace path_list is now [project-a, project-b]. The old - // thread was stored under [project-a], so it no longer appears in - // the sidebar list for this workspace. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - !entries.iter().any(|e| e.contains("Hello")), - "Thread stored under the old path_list should not appear: {:?}", - entries - ); - - // The "New Thread" button must still be clickable (not stuck in - // "active/draft" state). Verify that `active_thread_is_draft` is - // false — the panel still has the old thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "After adding a folder the panel still has a thread with messages, \ - so active_thread_is_draft should be false" - ); - }); - - // Actually click "New Thread" by calling create_new_thread and - // verify a new draft is created. - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.create_new_thread(&workspace, window, cx); - }); - cx.run_until_parked(); - - // After creating a new thread, the panel should now be in draft - // state (no messages on the new thread). - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.active_thread_is_draft, - "After creating a new thread the panel should be in draft state" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { - // When the user presses Cmd-N (NewThread action) while viewing a - // non-empty thread, the sidebar should show the "New Thread" entry. - // This exercises the same code path as the workspace action handler - // (which bypasses the sidebar's create_new_thread method). - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create a non-empty thread (has messages). - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate cmd-n - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"], - "After Cmd-N the sidebar should show a highlighted New Thread entry" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { - // When the active workspace is an absorbed git worktree, cmd-n - // should still show the "New Thread" entry under the main repo's - // header and highlight it as active. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch to the worktree workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Create a non-empty thread in the worktree workspace. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&worktree_panel, connection, cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_path_list, cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} *"] - ); - - // Simulate Cmd-N in the worktree workspace. - worktree_panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - worktree_workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} *" - ], - "After Cmd-N in an absorbed worktree, the sidebar should show \ - a highlighted New Thread entry under the main repo header" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - async fn init_test_project_with_git( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> (Entity, Arc) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - worktree_path, - serde_json::json!({ - ".git": {}, - "src": {}, - }), - ) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; - (project, fs) - } - - #[gpui::test] - async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", cx).await; - - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; - save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Search for "rosewood" — should match the worktree name, not the title. - type_in_search(&sidebar, "rosewood", cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Fix Bug {rosewood} <== selected"], - ); - } - - #[gpui::test] - async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", cx).await; - - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread against a worktree path that doesn't exist yet. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread is not visible yet — no worktree knows about this path. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " [+ New Thread]"] - ); - - // Now add the worktree to the git state and trigger a rescan. - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Worktree Thread {rosewood}",] - ); - } - - #[gpui::test] - async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Create the main repo directory (not opened as a workspace yet). - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - "feature-b": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-b", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Two worktree checkouts whose .git files point back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-b", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-b", - "src": {}, - }), - ) - .await; - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; - - project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; - project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - // Open both worktrees as workspaces — no main repo yet. - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Without the main repo, each worktree has its own header. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Thread A {wt-feature-a}", - " Thread B {wt-feature-b}", - ] - ); - - // Configure the main repo to list both worktrees before opening - // it so the initial git scan picks them up. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-b"), - ref_name: Some("refs/heads/feature-b".into()), - sha: "bbb".into(), - }); - }) - .unwrap(); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(main_project.clone(), window, cx); - }); - cx.run_until_parked(); - - // Both worktree workspaces should now be absorbed under the main - // repo header, with worktree chips. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Thread A {wt-feature-a}", - " Thread B {wt-feature-b}", - ] - ); - - // Remove feature-b from the main repo's linked worktrees. - // The feature-b workspace should be pruned automatically. - fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state - .worktrees - .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b")); - }) - .unwrap(); - - cx.run_until_parked(); - - // feature-b's workspace is pruned; feature-a remains absorbed - // under the main repo. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Thread A {wt-feature-a}",] - ); - } - - #[gpui::test] - async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { - // A thread created in a workspace with roots from different git - // worktrees should show a chip for each distinct worktree name. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Two main repos. - fs.insert_tree( - "/project_a", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - "selectric": { - "commondir": "../../", - "HEAD": "ref: refs/heads/selectric", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/project_b", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - "selectric": { - "commondir": "../../", - "HEAD": "ref: refs/heads/selectric", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkouts. - for (repo, branch) in &[ - ("project_a", "olivetti"), - ("project_a", "selectric"), - ("project_b", "olivetti"), - ("project_b", "selectric"), - ] { - let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); - let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); - fs.insert_tree( - &worktree_path, - serde_json::json!({ - ".git": gitdir, - "src": {}, - }), - ) - .await; - } - - // Register linked worktrees. - for repo in &["project_a", "project_b"] { - let git_path = format!("/{repo}/.git"); - fs.with_git_state(std::path::Path::new(&git_path), false, |state| { - for branch in &["olivetti", "selectric"] { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from(format!( - "/worktrees/{repo}/{branch}/{repo}" - )), - ref_name: Some(format!("refs/heads/{branch}").into()), - sha: "aaa".into(), - }); - } - }) - .unwrap(); - } - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Open a workspace with the worktree checkout paths as roots - // (this is the workspace the thread was created in). - let project = project::Project::test( - fs.clone(), - [ - "/worktrees/project_a/olivetti/project_a".as_ref(), - "/worktrees/project_b/selectric/project_b".as_ref(), - ], - cx, - ) - .await; - project.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread under the same paths as the workspace roots. - let thread_paths = PathList::new(&[ - std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), - std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), - ]); - save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show two distinct worktree chips. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project_a, project_b]", - " Cross Worktree Thread {olivetti}, {selectric}", - ] - ); - } - - #[gpui::test] - async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { - // When a thread's roots span multiple repos but share the same - // worktree name (e.g. both in "olivetti"), only one chip should - // appear. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project_a", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/project_b", - serde_json::json!({ - ".git": { - "worktrees": { - "olivetti": { - "commondir": "../../", - "HEAD": "ref: refs/heads/olivetti", - }, - }, - }, - "src": {}, - }), - ) - .await; - - for repo in &["project_a", "project_b"] { - let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); - let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); - fs.insert_tree( - &worktree_path, - serde_json::json!({ - ".git": gitdir, - "src": {}, - }), - ) - .await; - - let git_path = format!("/{repo}/.git"); - fs.with_git_state(std::path::Path::new(&git_path), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), - ref_name: Some("refs/heads/olivetti".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - } - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project = project::Project::test( - fs.clone(), - [ - "/worktrees/project_a/olivetti/project_a".as_ref(), - "/worktrees/project_b/olivetti/project_b".as_ref(), - ], - cx, - ) - .await; - project.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Thread with roots in both repos' "olivetti" worktrees. - let thread_paths = PathList::new(&[ - std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), - std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), - ]); - save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Both worktree paths have the name "olivetti", so only one chip. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project_a, project_b]", - " Same Branch Thread {olivetti}", - ] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { - // When a worktree workspace is absorbed under the main repo, a - // running thread in the worktree's agent panel should still show - // live status (spinner + "(running)") in the sidebar. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - // Create the MultiWorkspace with both projects. - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Add an agent panel to the worktree workspace so we can run a - // thread inside it. - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch back to the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Start a thread in the worktree workspace's panel and keep it - // generating (don't resolve it). - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - - // Save metadata so the sidebar knows about this thread. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - // Keep the thread generating by sending a chunk without ending - // the turn. - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // The worktree thread should be absorbed under the main project - // and show live running status. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - entries, - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - - connection.end_turn(session_id, acp::StopReason::EndTurn); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (!)",] - ); - } - - #[gpui::test] - async fn test_clicking_worktree_thread_opens_workspace_when_none_exists( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Only open the main repo — no workspace for the worktree. - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread for the worktree path (no workspace for it). - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread should appear under the main repo with a worktree chip. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], - ); - - // Only 1 workspace should exist. - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 1, - ); - - // Focus the sidebar and select the worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); // index 0 is header, 1 is the thread - }); - - // Confirm to open the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // A new workspace should have been created for the worktree path. - let new_workspace = multi_workspace.read_with(cx, |mw, _| { - assert_eq!( - mw.workspaces().len(), - 2, - "confirming a worktree thread without a workspace should open one", - ); - mw.workspaces()[1].clone() - }); - - 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")]), - "the new workspace should have been opened for the worktree path", - ); - } - - #[gpui::test] - async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], - ); - - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); - }); - - let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { - let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { - if let ListEntry::ProjectHeader { label, .. } = entry { - Some(label.as_ref()) - } else { - None - } - }); - - let Some(project_header) = project_headers.next() else { - panic!("expected exactly one sidebar project header named `project`, found none"); - }; - assert_eq!( - project_header, "project", - "expected the only sidebar project header to be `project`" - ); - if let Some(unexpected_header) = project_headers.next() { - panic!( - "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" - ); - } - - let mut saw_expected_thread = false; - for entry in &sidebar.contents.entries { - match entry { - ListEntry::ProjectHeader { label, .. } => { - assert_eq!( - label.as_ref(), - "project", - "expected the only sidebar project header to be `project`" - ); - } - ListEntry::Thread(thread) - if thread - .session_info - .title - .as_ref() - .map(|title| title.as_ref()) - == Some("WT Thread") - && thread.worktrees.first().map(|wt| wt.name.as_ref()) - == Some("wt-feature-a") => - { - saw_expected_thread = true; - } - ListEntry::Thread(thread) => { - let title = thread - .session_info - .title - .as_ref() - .map(|title| title.as_ref()) - .unwrap_or("Untitled"); - let worktree_name = thread - .worktrees - .first() - .map(|wt| wt.name.as_ref()) - .unwrap_or(""); - panic!( - "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" - ); - } - ListEntry::ViewMore { .. } => { - panic!("unexpected `View More` entry while opening linked worktree thread"); - } - ListEntry::NewThread { .. } => { - panic!( - "unexpected `New Thread` entry while opening linked worktree thread" - ); - } - } - } - - assert!( - saw_expected_thread, - "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" - ); - }; - - sidebar - .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) - .detach(); - - let window = cx.windows()[0]; - cx.update_window(window, |_, window, cx| { - window.dispatch_action(Confirm.boxed_clone(), cx); - }) - .unwrap(); - - cx.run_until_parked(); - - sidebar.update(cx, assert_sidebar_state); - } - - #[gpui::test] - async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Activate the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // The worktree workspace should be absorbed under the main repo. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 3); - assert_eq!(entries[0], "v [project]"); - assert!(entries.contains(&" Main Thread".to_string())); - assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); - - let wt_thread_index = entries - .iter() - .position(|e| e.contains("WT Thread")) - .expect("should find the worktree thread entry"); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0, - "main workspace should be active initially" - ); - - // Focus the sidebar and select the absorbed worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(wt_thread_index); - }); - - // Confirm to activate the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // The worktree workspace should now be active, not the main one. - let active_workspace = multi_workspace.read_with(cx, |mw, _| { - mw.workspaces()[mw.active_workspace_index()].clone() - }); - assert_eq!( - active_workspace, worktree_workspace, - "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_test_thread_metadata(&session_id, path_list_b.clone(), 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(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - 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")), - work_dirs: Some(PathList::new(&[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")), - work_dirs: 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")); - - 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(), - work_dirs: Some(path_list_b), - 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" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window( - cx: &mut TestAppContext, - ) { - 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_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); - - sidebar.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( - cx: &mut TestAppContext, - ) { - 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_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); - let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); - let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); - let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - sidebar_b.read_with(cx_b, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "target window's sidebar should eagerly focus the activated archived thread" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-current-window")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])), - title: Some("Current Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, - "should keep activation in the current window when it already has a matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "current window's sidebar should eagerly focus the activated archived thread" - ); - }); - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "current window should continue reusing its existing workspace" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "other windows should not be activated just because they also match the saved paths" - ); - } - - #[gpui::test] - async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) { - // Regression test: archive_thread previously always loaded the next thread - // through group_workspace (the main workspace's ProjectHeader), even when - // the next thread belonged to an absorbed linked-worktree workspace. That - // caused the worktree thread to be loaded in the main panel, which bound it - // to the main project and corrupted its stored folder_paths. - // - // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available, - // falling back to group_workspace only for Closed workspaces. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Activate main workspace so the sidebar tracks the main panel. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); - let main_panel = add_agent_panel(&main_workspace, &main_project, cx); - let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Open Thread 2 in the main panel and keep it running. - let connection = StubAgentConnection::new(); - open_thread_with_connection(&main_panel, connection.clone(), cx); - send_message(&main_panel, cx); - - let thread2_session_id = active_session_id(&main_panel, cx); - - cx.update(|_, cx| { - connection.send_update( - thread2_session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - - // Save thread 2's metadata with a newer timestamp so it sorts above thread 1. - save_thread_metadata( - thread2_session_id.clone(), - "Thread 2".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - PathList::new(&[std::path::PathBuf::from("/project")]), - cx, - ) - .await; - - // Save thread 1's metadata with the worktree path and an older timestamp so - // it sorts below thread 2. archive_thread will find it as the "next" candidate. - let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session")); - save_thread_metadata( - thread1_session_id.clone(), - "Thread 1".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), - cx, - ) - .await; - - cx.run_until_parked(); - - // Verify the sidebar absorbed thread 1 under [project] with the worktree chip. - let entries_before = visible_entries_as_strings(&sidebar, cx); - assert!( - entries_before.iter().any(|s| s.contains("{wt-feature-a}")), - "Thread 1 should appear with the linked-worktree chip before archiving: {:?}", - entries_before - ); - - // The sidebar should track T2 as the focused thread (derived from the - // main panel's active view). - let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone()); - assert_eq!( - focused, - Some(thread2_session_id.clone()), - "focused thread should be Thread 2 before archiving: {:?}", - focused - ); - - // Archive thread 2. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.archive_thread(&thread2_session_id, window, cx); - }); - - cx.run_until_parked(); - - // The main panel's active thread must still be thread 2. - let main_active = main_panel.read_with(cx, |panel, cx| { - panel - .active_agent_thread(cx) - .map(|t| t.read(cx).session_id().clone()) - }); - assert_eq!( - main_active, - Some(thread2_session_id.clone()), - "main panel should not have been taken over by loading the linked-worktree thread T1; \ - before the fix, archive_thread used group_workspace instead of next.workspace, \ - causing T1 to be loaded in the wrong panel" - ); - - // Thread 1 should still appear in the sidebar with its worktree chip - // (Thread 2 was archived so it is gone from the list). - let entries_after = visible_entries_as_strings(&sidebar, cx); - assert!( - entries_after.iter().any(|s| s.contains("{wt-feature-a}")), - "T1 should still carry its linked-worktree chip after archiving T2: {:?}", - entries_after - ); - } - - #[gpui::test] - async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { - // When a multi-root workspace (e.g. [/other, /project]) shares a - // repo with a single-root workspace (e.g. [/project]), linked - // worktree threads from the shared repo should only appear under - // the dedicated group [project], not under [other, project]. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Two independent repos, each with their own git history. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/other", - serde_json::json!({ - ".git": {}, - "src": {}, - }), - ) - .await; - - // Register the linked worktree in the main repo. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Workspace 1: just /project. - let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - project_only - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - // Workspace 2: /other and /project together (multi-root). - let multi_root = - project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; - multi_root - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(project_only.clone(), window, cx) - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(multi_root.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread under the linked worktree path. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // The thread should appear only under [project] (the dedicated - // group for the /project repo), not under [other, project]. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Worktree Thread {wt-feature-a}", - "v [other, project]", - " [+ New Thread]", - ] - ); - } -} diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..04b2d129e0fadabbb3da07364d31ccbbaa965f35 --- /dev/null +++ b/crates/sidebar/src/sidebar_tests.rs @@ -0,0 +1,4664 @@ +use super::*; +use acp_thread::StubAgentConnection; +use agent::ThreadStore; +use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; +use assistant_text_thread::TextThreadStore; +use chrono::DateTime; +use feature_flags::FeatureFlagAppExt as _; +use fs::FakeFs; +use gpui::TestAppContext; +use pretty_assertions::assert_eq; +use settings::SettingsStore; +use std::{path::PathBuf, sync::Arc}; +use util::path_list::PathList; + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); +} + +fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { + sidebar.contents.entries.iter().any( + |entry| matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id), + ) +} + +async fn init_test_project( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn setup_sidebar( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + 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(cx, |mw, cx| { + mw.register_sidebar(sidebar.clone(), cx); + }); + cx.run_until_parked(); + sidebar +} + +async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) { + for i in 0..count { + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + format!("Thread {}", i + 1).into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); +} + +async fn save_test_thread_metadata( + session_id: &acp::SessionId, + path_list: PathList, + cx: &mut TestAppContext, +) { + save_thread_metadata( + session_id.clone(), + "Test".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list, + cx, + ) + .await; +} + +async fn save_named_thread_metadata( + session_id: &str, + title: &str, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, +) { + save_thread_metadata( + acp::SessionId::new(Arc::from(session_id)), + SharedString::from(title.to_string()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); +} + +async fn save_thread_metadata( + session_id: acp::SessionId, + title: SharedString, + updated_at: DateTime, + path_list: PathList, + cx: &mut TestAppContext, +) { + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + cx.update(|cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + }); + cx.run_until_parked(); +} + +fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); + if let Some(multi_workspace) = multi_workspace { + multi_workspace.update_in(cx, |mw, window, cx| { + if !mw.sidebar_open() { + mw.toggle_sidebar(window, cx); + } + }); + } + cx.run_until_parked(); + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); +} + +fn visible_entries_as_strings( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Vec { + sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let selected = if sidebar.selection == Some(ix) { + " <== selected" + } else { + "" + }; + match entry { + ListEntry::ProjectHeader { + label, + path_list, + highlight_positions: _, + .. + } => { + let icon = if sidebar.collapsed_groups.contains(path_list) { + ">" + } else { + "v" + }; + format!("{} [{}]{}", icon, label, selected) + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or("Untitled"); + let active = if thread.is_live { " *" } else { "" }; + let status_str = match thread.status { + AgentThreadStatus::Running => " (running)", + AgentThreadStatus::Error => " (error)", + AgentThreadStatus::WaitingForConfirmation => " (waiting)", + _ => "", + }; + let notified = if sidebar + .contents + .is_thread_notified(&thread.session_info.session_id) + { + " (!)" + } else { + "" + }; + let worktree = if thread.worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in &thread.worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; + format!( + " {}{}{}{}{}{}", + title, worktree, active, status_str, notified, selected + ) + } + ListEntry::ViewMore { + is_fully_expanded, .. + } => { + if *is_fully_expanded { + format!(" - Collapse{}", selected) + } else { + format!(" + View More{}", selected) + } + } + ListEntry::NewThread { .. } => { + format!(" [+ New Thread]{}", selected) + } + } + }) + .collect() + }) +} + +#[test] +fn test_clean_mention_links() { + // Simple mention link + assert_eq!( + Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), + "check @Button.tsx" + ); + + // Multiple mention links + assert_eq!( + Sidebar::clean_mention_links( + "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" + ), + "look at @foo.rs and @bar.rs" + ); + + // No mention links — passthrough + assert_eq!( + Sidebar::clean_mention_links("plain text with no mentions"), + "plain text with no mentions" + ); + + // Incomplete link syntax — preserved as-is + assert_eq!( + Sidebar::clean_mention_links("broken [@mention without closing"), + "broken [@mention without closing" + ); + + // Regular markdown link (no @) — not touched + assert_eq!( + Sidebar::clean_mention_links("see [docs](https://example.com)"), + "see [docs](https://example.com)" + ); + + // Empty input + assert_eq!(Sidebar::clean_mention_links(""), ""); +} + +#[gpui::test] +async fn test_entities_released_on_window_close(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); + let weak_sidebar = sidebar.downgrade(); + let weak_multi_workspace = multi_workspace.downgrade(); + + drop(sidebar); + drop(multi_workspace); + cx.update(|window, _cx| window.remove_window()); + cx.run_until_parked(); + + weak_multi_workspace.assert_released(); + weak_sidebar.assert_released(); + weak_workspace.assert_released(); +} + +#[gpui::test] +async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]"] + ); +} + +#[gpui::test] +async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix crash in project panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-2")), + "Add inline diff view".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + ] + ); +} + +#[gpui::test] +async fn test_workspace_lifecycle(cx: &mut TestAppContext) { + let project = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Single workspace with a thread + let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-a1")), + "Thread A1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); + + // Add a second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1",] + ); + + // Remove the second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.remove_workspace(1, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); +} + +#[gpui::test] +async fn test_view_more_pagination(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(12, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Thread 12", + " Thread 11", + " Thread 10", + " Thread 9", + " Thread 8", + " + View More", + ] + ); +} + +#[gpui::test] +async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse + save_n_test_threads(17, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Initially shows 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus and navigate to View More, then confirm to expand by one batch + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // Now shows 10 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand again by one batch + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Now shows 15 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand one more time - should show all 17 threads with Collapse button + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // All 17 threads shown with Collapse button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 19); // header + 17 threads + Collapse + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); + + // Click collapse - should go back to showing 5 threads + sidebar.update_in(cx, |s, _window, cx| { + s.expanded_groups.remove(&path_list); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Back to initial state: 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); +} + +#[gpui::test] +async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Collapse + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + + // Expand + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); +} + +#[gpui::test] +async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + 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")]); + + sidebar.update_in(cx, |s, _window, _cx| { + s.collapsed_groups.insert(collapsed_path.clone()); + s.contents + .notified_threads + .insert(acp::SessionId::new(Arc::from("t-5"))); + s.contents.entries = vec![ + // Expanded project header + ListEntry::ProjectHeader { + path_list: expanded_path.clone(), + label: "expanded-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: true, + }, + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-1")), + work_dirs: None, + title: Some("Completed thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Running thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Running, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Error thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Error, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Waiting thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::WaitingForConfirmation, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Notified thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: true, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // View More entry + ListEntry::ViewMore { + path_list: expanded_path.clone(), + is_fully_expanded: false, + }, + // Collapsed project header + ListEntry::ProjectHeader { + path_list: collapsed_path.clone(), + label: "collapsed-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: false, + }, + ]; + + // Select the Running thread (index 2) + s.selection = Some(2); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [expanded-project]", + " Completed thread", + " Running thread * (running) <== selected", + " Error thread * (error)", + " Waiting thread (waiting)", + " Notified thread * (!)", + " + View More", + "> [collapsed-project]", + ] + ); + + // Move selection to the collapsed header + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = Some(7); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx).last().cloned(), + Some("> [collapsed-project] <== selected".to_string()), + ); + + // Clear selection + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = None; + }); + + // No entry should have the selected marker + let entries = visible_entries_as_strings(&sidebar, cx); + for entry in &entries { + assert!( + !entry.contains("<== selected"), + "unexpected selection marker in: {}", + entry + ); + } +} + +#[gpui::test] +async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Entries: [header, thread3, thread2, thread1] + // Focusing the sidebar does not set a selection; select_next/select_previous + // handle None gracefully by starting from the first or last entry. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Move down through remaining entries + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Navigate back to the end + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // Move back up + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // At the top, selection clears (focus returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // SelectLast jumps to the end + cx.dispatch_action(SelectLast); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // SelectFirst jumps to the beginning + cx.dispatch_action(SelectFirst); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Initially no selection + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Open the sidebar so it's rendered, then focus it to trigger focus_in. + // focus_in no longer sets a default selection. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Manually set a selection, blur, then refocus — selection should be preserved + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.update(|window, _cx| { + window.blur(); + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus the sidebar and select the header (index 0) + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + // Confirm on project header collapses the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Confirm again expands the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(8, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show header + 5 threads + "View More" + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); + + // Confirm on "View More" to expand + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // All 8 threads should now be visible with a "Collapse" button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); +} + +#[gpui::test] +async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus sidebar and manually select the header (index 0). Press left to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Press right to expand + cx.dispatch_action(SelectChild); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); + + // Press right again on already-expanded header moves selection down + cx.dispatch_action(SelectChild); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); +} + +#[gpui::test] +async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), then navigate down to the thread (child) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1 <== selected",] + ); + + // Pressing left on a child collapses the parent group and selects it + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); +} + +#[gpui::test] +async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { + let project = init_test_project("/empty-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // An empty project has the header and a new thread button. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [empty-project]", " [+ New Thread]"] + ); + + // Focus sidebar — focus_in does not set a selection + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 (header) + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectNext moves to the new thread button + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectPrevious from first entry clears selection (returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // Collapse the group, which removes the thread from the list + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + // Selection should be clamped to the last valid index (0 = header) + let selection = sidebar.read_with(cx, |s, _| s.selection); + let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); + assert!( + selection.unwrap_or(0) < entry_count, + "selection {} should be within bounds (entries: {})", + selection.unwrap_or(0), + entry_count, + ); +} + +async fn init_test_project_with_agent_panel( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn add_agent_panel( + workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) +} + +fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> (Entity, Entity) { + let sidebar = setup_sidebar(multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let panel = add_agent_panel(&workspace, project, cx); + (sidebar, panel) +} + +#[gpui::test] +async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Open thread A and keep it generating. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection.clone(), cx); + send_message(&panel, cx); + + let session_id_a = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Open thread B (idle, default response) — thread A goes to background. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id_b = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; + + cx.run_until_parked(); + + let mut entries = visible_entries_as_strings(&sidebar, cx); + entries[1..].sort(); + assert_eq!( + entries, + vec!["v [my-project]", " Hello *", " Hello * (running)",] + ); +} + +#[gpui::test] +async fn test_background_thread_completion_triggers_notification(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")]); + + // Open thread on workspace A and keep it generating. + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel_a, connection_a.clone(), cx); + send_message(&panel_a, cx); + + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Add a second workspace and activate it (making workspace A the background). + let fs = cx.update(|_, cx| ::global(cx)); + let project_b = project::Project::test(fs, [], cx).await; + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + cx.run_until_parked(); + + // Thread A is still running; no notification yet. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (running)",] + ); + + // Complete thread A's turn (transition Running → Completed). + connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); + cx.run_until_parked(); + + // The completed background thread shows a notification indicator. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (!)",] + ); +} + +fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, window, cx| { + window.focus(&sidebar.filter_editor.focus_handle(cx), cx); + sidebar.filter_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + }); + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in project panel", 3), + ("t-2", "Add inline diff view", 2), + ("t-3", "Refactor settings module", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + " Refactor settings module", + ] + ); + + // User types "diff" in the search box — only the matching thread remains, + // with its workspace header preserved for context. + type_in_search(&sidebar, "diff", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Add inline diff view <== selected",] + ); + + // User changes query to something with no matches — list is empty. + type_in_search(&sidebar, "nonexistent", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + +#[gpui::test] +async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { + // Scenario: A user remembers a thread title but not the exact casing. + // Search should match case-insensitively so they can still find it. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix Crash In Project Panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // Lowercase query matches mixed-case title. + type_in_search(&sidebar, "fix crash", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + + // Uppercase query also matches the same title. + type_in_search(&sidebar, "FIX CRASH", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); +} + +#[gpui::test] +async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { + // Scenario: A user searches, finds what they need, then presses Escape + // to dismiss the filter and see the full list again. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the full list is showing. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread", " Beta thread",] + ); + + // User types a search query to filter down. + open_and_focus_sidebar(&sidebar, cx); + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread <== selected",] + ); + + // User presses Escape — filter clears, full list is restored. + // The selection index (1) now points at the first thread entry. + cx.dispatch_action(Cancel); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Alpha thread <== selected", + " Beta thread", + ] + ); +} + +#[gpui::test] +async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", 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); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar", + " Add tests for editor", + ] + ); + + // "sidebar" matches a thread in each workspace — both headers stay visible. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Fix bug in sidebar <== selected",] + ); + + // "typo" only matches in the second workspace — the first header disappears. + type_in_search(&sidebar, "typo", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + + // "project-a" matches the first workspace name — the header appears + // with all child threads included. + type_in_search(&sidebar, "project-a", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { + let project_a = init_test_project("/alpha-project", 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); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // "alpha" matches the workspace name "alpha-project" but no thread titles. + // The workspace header should appear with all child threads included. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // "sidebar" matches thread titles in both workspaces but not workspace names. + // Both headers appear with their matching threads. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r + // doesn't match) — but does not match either workspace name or any thread. + // Actually let's test something simpler: a query that matches both a workspace + // name AND some threads in that workspace. Matching threads should still appear. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // A query that matches a workspace name AND a thread in that same workspace. + // Both the header (highlighted) and all child threads should appear. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // Now search for something that matches only a workspace name when there + // are also threads with matching titles — the non-matching workspace's + // threads should still appear if their titles match. + type_in_search(&sidebar, "alp", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create 8 threads. The oldest one has a unique name and will be + // behind View More (only 5 shown by default). + for i in 0..8u32 { + let title = if i == 0 { + "Hidden gem thread".to_string() + } else { + format!("Thread {}", i + 1) + }; + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the thread is not visible and View More is shown. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("View More")), + "should have View More button" + ); + assert!( + !entries.iter().any(|e| e.contains("Hidden gem")), + "Hidden gem should be behind View More" + ); + + // User searches for the hidden thread — it appears, and View More is gone. + type_in_search(&sidebar, "hidden gem", cx); + let filtered = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + filtered, + vec!["v [my-project]", " Hidden gem thread <== selected",] + ); + assert!( + !filtered.iter().any(|e| e.contains("View More")), + "View More should not appear when filtering" + ); +} + +#[gpui::test] +async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Important thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // User focuses the sidebar and collapses the group using keyboard: + // manually select the header, then press SelectParent to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // User types a search — the thread appears even though its group is collapsed. + type_in_search(&sidebar, "important", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]", " Important thread <== selected",] + ); +} + +#[gpui::test] +async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in panel", 3), + ("t-2", "Fix lint warnings", 2), + ("t-3", "Add new feature", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // User types "fix" — two threads match. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + + // Selection starts on the first matching thread. User presses + // SelectNext to move to the second match. + cx.dispatch_action(SelectNext); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel", + " Fix lint warnings <== selected", + ] + ); + + // User can also jump back with SelectPrevious. + cx.dispatch_action(SelectPrevious); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); +} + +#[gpui::test] +async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("hist-1")), + "Historical Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Historical Thread",] + ); + + // Switch to workspace 1 so we can verify the confirm switches back. + 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 + ); + + // Confirm on the historical (non-live) thread at index 1. + // 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); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); +} + +#[gpui::test] +async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-1")), + "Thread A".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-2")), + "Thread B".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread A", " Thread B",] + ); + + // Keyboard confirm preserves selection. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.selection), + Some(1) + ); + + // Click handlers clear selection to None so no highlight lingers + // after a click regardless of focus state. The hover style provides + // visual feedback during mouse interaction instead. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = None; + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + sidebar.toggle_collapse(&path_list, window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + + // When the user tabs back into the sidebar, focus_in no longer + // restores selection — it stays None. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.focus_in(window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); +} + +#[gpui::test] +async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate the agent generating a title. The notification chain is: + // AcpThread::set_title emits TitleUpdated → + // ConnectionView::handle_thread_event calls cx.notify() → + // AgentPanel observer fires and emits AgentPanelEvent → + // Sidebar subscription calls update_entries / rebuild_contents. + // + // Before the fix, handle_thread_event did NOT call cx.notify() for + // TitleUpdated, so the AgentPanel observer never fired and the + // sidebar kept showing the old title. + let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + thread.update(cx, |thread, cx| { + thread + .set_title("Friendly Greeting with AI".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + 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_test_thread_metadata(&session_id_a, path_list_a.clone(), 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: focused thread derived from active panel ───── + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "The active panel's thread should be focused on startup" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id_a.clone(), + work_dirs: None, + title: Some("Test".into()), + updated_at: None, + created_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" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The clicked thread should be present in the entries" + ); + }); + + 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_test_thread_metadata(&session_id_b, path_list_b.clone(), 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( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id_b.clone(), + work_dirs: None, + title: Some("Thread B".into()), + updated_at: None, + created_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" + ); + assert!( + has_thread_entry(sidebar, &session_id_b), + "The cross-workspace thread should be present in the entries" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The seeded thread should be present in the entries" + ); + }); + + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new(DEFAULT_THREAD_TITLE.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_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Panel B is not the active workspace's panel (workspace A is + // active), so opening a thread there should not change focused_thread. + // This prevents running threads in background workspaces from causing + // the selection highlight to jump around. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active panel should not change 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_a), + "Defocusing the sidebar should not change focused_thread" + ); + }); + + // Switching workspaces via the multi_workspace (simulates clicking + // a workspace header) should clear focused_thread. + multi_workspace.update_in(cx, |mw, window, cx| { + if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { + mw.activate_index(index, window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The seeded thread should be present in the entries" + ); + }); + + // ── 8. Focusing the agent panel thread keeps focused_thread ──── + // Workspace B still has session_id_b2 loaded in the agent panel. + // Clicking into the thread (simulated by focusing its view) should + // keep focused_thread since it was already seeded on workspace switch. + panel_b.update_in(cx, |panel, window, cx| { + if let Some(thread_view) = panel.active_conversation_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" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The focused thread should be present in the entries" + ); + }); +} + +#[gpui::test] +async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/project-a", cx).await; + let fs = cx.update(|cx| ::global(cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Start a thread and send a message so it has history. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; + cx.run_until_parked(); + + // Verify the thread appears in the sidebar. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello *",] + ); + + // The "New Thread" button should NOT be in "active/draft" state + // because the panel has a thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "Panel has a thread with messages, so it should not be a draft" + ); + }); + + // Now add a second folder to the workspace, changing the path_list. + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // The workspace path_list is now [project-a, project-b]. The old + // thread was stored under [project-a], so it no longer appears in + // the sidebar list for this workspace. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries.iter().any(|e| e.contains("Hello")), + "Thread stored under the old path_list should not appear: {:?}", + entries + ); + + // The "New Thread" button must still be clickable (not stuck in + // "active/draft" state). Verify that `active_thread_is_draft` is + // false — the panel still has the old thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "After adding a folder the panel still has a thread with messages, \ + so active_thread_is_draft should be false" + ); + }); + + // Actually click "New Thread" by calling create_new_thread and + // verify a new draft is created. + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + // After creating a new thread, the panel should now be in draft + // state (no messages on the new thread). + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.active_thread_is_draft, + "After creating a new thread the panel should be in draft state" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { + // When the user presses Cmd-N (NewThread action) while viewing a + // non-empty thread, the sidebar should show the "New Thread" entry. + // This exercises the same code path as the workspace action handler + // (which bypasses the sidebar's create_new_thread method). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create a non-empty thread (has messages). + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate cmd-n + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]", " Hello *"], + "After Cmd-N the sidebar should show a highlighted New Thread entry" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { + // When the active workspace is an absorbed git worktree, cmd-n + // should still show the "New Thread" entry under the main repo's + // header and highlight it as active. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch to the worktree workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Create a non-empty thread in the worktree workspace. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&worktree_panel, connection, cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_path_list, cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} *"] + ); + + // Simulate Cmd-N in the worktree workspace. + worktree_panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + worktree_workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} *" + ], + "After Cmd-N in an absorbed worktree, the sidebar should show \ + a highlighted New Thread entry under the main repo header" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); +} + +async fn init_test_project_with_git( + worktree_path: &str, + cx: &mut TestAppContext, +) -> (Entity, Arc) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + worktree_path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; + (project, fs) +} + +#[gpui::test] +async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Search for "rosewood" — should match the worktree name, not the title. + type_in_search(&sidebar, "rosewood", cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Fix Bug {rosewood} <== selected"], + ); +} + +#[gpui::test] +async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread against a worktree path that doesn't exist yet. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread is not visible yet — no worktree knows about this path. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " [+ New Thread]"] + ); + + // Now add the worktree to the git state and trigger a rescan. + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), true, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Worktree Thread {rosewood}",] + ); +} + +#[gpui::test] +async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Create the main repo directory (not opened as a workspace yet). + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Two worktree checkouts whose .git files point back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Open both worktrees as workspaces — no main repo yet. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Without the main repo, each worktree has its own header. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); + + // Configure the main repo to list both worktrees before opening + // it so the initial git scan picks them up. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "bbb".into(), + }); + }) + .unwrap(); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(main_project.clone(), window, cx); + }); + cx.run_until_parked(); + + // Both worktree workspaces should now be absorbed under the main + // repo header, with worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); +} + +#[gpui::test] +async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { + // A thread created in a workspace with roots from different git + // worktrees should show a chip for each distinct worktree name. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two main repos. + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkouts. + for (repo, branch) in &[ + ("project_a", "olivetti"), + ("project_a", "selectric"), + ("project_b", "olivetti"), + ("project_b", "selectric"), + ] { + let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + } + + // Register linked worktrees. + for repo in &["project_a", "project_b"] { + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + for branch in &["olivetti", "selectric"] { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")), + ref_name: Some(format!("refs/heads/{branch}").into()), + sha: "aaa".into(), + }); + } + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open a workspace with the worktree checkout paths as roots + // (this is the workspace the thread was created in). + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/selectric/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the same paths as the workspace roots. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show two distinct worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Cross Worktree Thread {olivetti}, {selectric}", + ] + ); +} + +#[gpui::test] +async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { + // When a thread's roots span multiple repos but share the same + // worktree name (e.g. both in "olivetti"), only one chip should + // appear. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + + for repo in &["project_a", "project_b"] { + let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), + ref_name: Some("refs/heads/olivetti".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/olivetti/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Thread with roots in both repos' "olivetti" worktrees. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Both worktree paths have the name "olivetti", so only one chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Same Branch Thread {olivetti}", + ] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { + // When a worktree workspace is absorbed under the main repo, a + // running thread in the worktree's agent panel should still show + // live status (spinner + "(running)") in the sidebar. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Create the MultiWorkspace with both projects. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Add an agent panel to the worktree workspace so we can run a + // thread inside it. + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch back to the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start a thread in the worktree workspace's panel and keep it + // generating (don't resolve it). + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + + // Save metadata so the sidebar knows about this thread. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + // Keep the thread generating by sending a chunk without ending + // the turn. + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // The worktree thread should be absorbed under the main project + // and show live running status. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + entries, + vec!["v [project]", " Hello {wt-feature-a} * (running)",] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} * (running)",] + ); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} * (!)",] + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Only open the main repo — no workspace for the worktree. + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread for the worktree path (no workspace for it). + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread should appear under the main repo with a worktree chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + // Only 1 workspace should exist. + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + ); + + // Focus the sidebar and select the worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); // index 0 is header, 1 is the thread + }); + + // Confirm to open the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // A new workspace should have been created for the worktree path. + let new_workspace = multi_workspace.read_with(cx, |mw, _| { + assert_eq!( + mw.workspaces().len(), + 2, + "confirming a worktree thread without a workspace should open one", + ); + mw.workspaces()[1].clone() + }); + + 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")]), + "the new workspace should have been opened for the worktree path", + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); + }); + + let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { + let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { + if let ListEntry::ProjectHeader { label, .. } = entry { + Some(label.as_ref()) + } else { + None + } + }); + + let Some(project_header) = project_headers.next() else { + panic!("expected exactly one sidebar project header named `project`, found none"); + }; + assert_eq!( + project_header, "project", + "expected the only sidebar project header to be `project`" + ); + if let Some(unexpected_header) = project_headers.next() { + panic!( + "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" + ); + } + + let mut saw_expected_thread = false; + for entry in &sidebar.contents.entries { + match entry { + ListEntry::ProjectHeader { label, .. } => { + assert_eq!( + label.as_ref(), + "project", + "expected the only sidebar project header to be `project`" + ); + } + ListEntry::Thread(thread) + if thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + == Some("WT Thread") + && thread.worktrees.first().map(|wt| wt.name.as_ref()) + == Some("wt-feature-a") => + { + saw_expected_thread = true; + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + .unwrap_or("Untitled"); + let worktree_name = thread + .worktrees + .first() + .map(|wt| wt.name.as_ref()) + .unwrap_or(""); + panic!( + "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" + ); + } + ListEntry::ViewMore { .. } => { + panic!("unexpected `View More` entry while opening linked worktree thread"); + } + ListEntry::NewThread { .. } => { + panic!("unexpected `New Thread` entry while opening linked worktree thread"); + } + } + } + + assert!( + saw_expected_thread, + "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" + ); + }; + + sidebar + .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) + .detach(); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Confirm.boxed_clone(), cx); + }) + .unwrap(); + + cx.run_until_parked(); + + sidebar.update(cx, assert_sidebar_state); +} + +#[gpui::test] +async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The worktree workspace should be absorbed under the main repo. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0], "v [project]"); + assert!(entries.contains(&" Main Thread".to_string())); + assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); + + let wt_thread_index = entries + .iter() + .position(|e| e.contains("WT Thread")) + .expect("should find the worktree thread entry"); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0, + "main workspace should be active initially" + ); + + // Focus the sidebar and select the absorbed worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(wt_thread_index); + }); + + // Confirm to activate the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // The worktree workspace should now be active, not the main one. + let active_workspace = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces()[mw.active_workspace_index()].clone() + }); + assert_eq!( + active_workspace, worktree_workspace, + "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_test_thread_metadata(&session_id, path_list_b.clone(), 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(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + 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")), + work_dirs: Some(PathList::new(&[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")), + work_dirs: 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")); + + 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(), + work_dirs: Some(path_list_b), + 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" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) { + 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_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); + + sidebar.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Cross Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( + cx: &mut TestAppContext, +) { + 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_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); + let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); + let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); + let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Cross Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); + sidebar_b.read_with(cx_b, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "target window's sidebar should eagerly focus the activated archived thread" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-current-window")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])), + title: Some("Current Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, + "should keep activation in the current window when it already has a matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "current window's sidebar should eagerly focus the activated archived thread" + ); + }); + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "current window should continue reusing its existing workspace" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "other windows should not be activated just because they also match the saved paths" + ); +} + +#[gpui::test] +async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) { + // Regression test: archive_thread previously always loaded the next thread + // through group_workspace (the main workspace's ProjectHeader), even when + // the next thread belonged to an absorbed linked-worktree workspace. That + // caused the worktree thread to be loaded in the main panel, which bound it + // to the main project and corrupted its stored folder_paths. + // + // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available, + // falling back to group_workspace only for Closed workspaces. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate main workspace so the sidebar tracks the main panel. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); + let main_panel = add_agent_panel(&main_workspace, &main_project, cx); + let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Open Thread 2 in the main panel and keep it running. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&main_panel, connection.clone(), cx); + send_message(&main_panel, cx); + + let thread2_session_id = active_session_id(&main_panel, cx); + + cx.update(|_, cx| { + connection.send_update( + thread2_session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + + // Save thread 2's metadata with a newer timestamp so it sorts above thread 1. + save_thread_metadata( + thread2_session_id.clone(), + "Thread 2".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/project")]), + cx, + ) + .await; + + // Save thread 1's metadata with the worktree path and an older timestamp so + // it sorts below thread 2. archive_thread will find it as the "next" candidate. + let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session")); + save_thread_metadata( + thread1_session_id.clone(), + "Thread 1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + cx, + ) + .await; + + cx.run_until_parked(); + + // Verify the sidebar absorbed thread 1 under [project] with the worktree chip. + let entries_before = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_before.iter().any(|s| s.contains("{wt-feature-a}")), + "Thread 1 should appear with the linked-worktree chip before archiving: {:?}", + entries_before + ); + + // The sidebar should track T2 as the focused thread (derived from the + // main panel's active view). + let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone()); + assert_eq!( + focused, + Some(thread2_session_id.clone()), + "focused thread should be Thread 2 before archiving: {:?}", + focused + ); + + // Archive thread 2. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread(&thread2_session_id, window, cx); + }); + + cx.run_until_parked(); + + // The main panel's active thread must still be thread 2. + let main_active = main_panel.read_with(cx, |panel, cx| { + panel + .active_agent_thread(cx) + .map(|t| t.read(cx).session_id().clone()) + }); + assert_eq!( + main_active, + Some(thread2_session_id.clone()), + "main panel should not have been taken over by loading the linked-worktree thread T1; \ + before the fix, archive_thread used group_workspace instead of next.workspace, \ + causing T1 to be loaded in the wrong panel" + ); + + // Thread 1 should still appear in the sidebar with its worktree chip + // (Thread 2 was archived so it is gone from the list). + let entries_after = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_after.iter().any(|s| s.contains("{wt-feature-a}")), + "T1 should still carry its linked-worktree chip after archiving T2: {:?}", + entries_after + ); +} + +#[gpui::test] +async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { + // When a multi-root workspace (e.g. [/other, /project]) shares a + // repo with a single-root workspace (e.g. [/project]), linked + // worktree threads from the shared repo should only appear under + // the dedicated group [project], not under [other, project]. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two independent repos, each with their own git history. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/other", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + // Register the linked worktree in the main repo. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Workspace 1: just /project. + let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project_only + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Workspace 2: /other and /project together (multi-root). + let multi_root = + project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; + multi_root + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(multi_root.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the linked worktree path. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The thread should appear only under [project] (the dedicated + // group for the /project repo), not under [other, project]. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Worktree Thread {wt-feature-a}", + "v [other, project]", + " [+ New Thread]", + ] + ); +} + +mod property_test { + use super::*; + use gpui::EntityId; + + struct UnopenedWorktree { + path: String, + } + + struct TestState { + fs: Arc, + thread_counter: u32, + workspace_counter: u32, + worktree_counter: u32, + saved_thread_ids: Vec, + workspace_paths: Vec, + main_repo_indices: Vec, + unopened_worktrees: Vec, + } + + impl TestState { + fn new(fs: Arc, initial_workspace_path: String) -> Self { + Self { + fs, + thread_counter: 0, + workspace_counter: 1, + worktree_counter: 0, + saved_thread_ids: Vec::new(), + workspace_paths: vec![initial_workspace_path], + main_repo_indices: vec![0], + unopened_worktrees: Vec::new(), + } + } + + fn next_thread_id(&mut self) -> acp::SessionId { + let id = self.thread_counter; + self.thread_counter += 1; + let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}"))); + self.saved_thread_ids.push(session_id.clone()); + session_id + } + + fn remove_thread(&mut self, index: usize) -> acp::SessionId { + self.saved_thread_ids.remove(index) + } + + fn next_workspace_path(&mut self) -> String { + let id = self.workspace_counter; + self.workspace_counter += 1; + format!("/prop-project-{id}") + } + + fn next_worktree_name(&mut self) -> String { + let id = self.worktree_counter; + self.worktree_counter += 1; + format!("wt-{id}") + } + } + + #[derive(Debug)] + enum Operation { + SaveThread { workspace_index: usize }, + SaveWorktreeThread { worktree_index: usize }, + DeleteThread { index: usize }, + ToggleAgentPanel, + AddWorkspace, + OpenWorktreeAsWorkspace { worktree_index: usize }, + RemoveWorkspace { index: usize }, + SwitchWorkspace { index: usize }, + AddLinkedWorktree { workspace_index: usize }, + } + + // Distribution (out of 20 slots): + // SaveThread: 5 slots (25%) + // SaveWorktreeThread: 2 slots (10%) + // DeleteThread: 2 slots (10%) + // ToggleAgentPanel: 2 slots (10%) + // AddWorkspace: 1 slot (5%) + // OpenWorktreeAsWorkspace: 1 slot (5%) + // RemoveWorkspace: 1 slot (5%) + // SwitchWorkspace: 2 slots (10%) + // AddLinkedWorktree: 4 slots (20%) + const DISTRIBUTION_SLOTS: u32 = 20; + + impl TestState { + fn generate_operation(&self, raw: u32) -> Operation { + let extra = (raw / DISTRIBUTION_SLOTS) as usize; + let workspace_count = self.workspace_paths.len(); + + match raw % DISTRIBUTION_SLOTS { + 0..=4 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 5..=6 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread { + index: extra % self.saved_thread_ids.len(), + }, + 7..=8 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 9..=10 => Operation::ToggleAgentPanel, + 11 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 11 => Operation::AddWorkspace, + 12 if workspace_count > 1 => Operation::RemoveWorkspace { + index: extra % workspace_count, + }, + 12 => Operation::AddWorkspace, + 13..=14 => Operation::SwitchWorkspace { + index: extra % workspace_count, + }, + 15..=19 if !self.main_repo_indices.is_empty() => { + let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()]; + Operation::AddLinkedWorktree { + workspace_index: main_index, + } + } + 15..=19 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + _ => unreachable!(), + } + } + } + + fn save_thread_to_path( + state: &mut TestState, + path_list: PathList, + cx: &mut gpui::VisualTestContext, + ) { + let session_id = state.next_thread_id(); + let title: SharedString = format!("Thread {}", session_id).into(); + let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0) + .unwrap() + + chrono::Duration::seconds(state.thread_counter as i64); + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + } + + async fn perform_operation( + operation: Operation, + state: &mut TestState, + multi_workspace: &Entity, + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) { + match operation { + Operation::SaveThread { workspace_index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let path_list = workspace + .read_with(cx, |workspace, cx| PathList::new(&workspace.root_paths(cx))); + save_thread_to_path(state, path_list, cx); + } + Operation::SaveWorktreeThread { worktree_index } => { + let worktree = &state.unopened_worktrees[worktree_index]; + let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]); + save_thread_to_path(state, path_list, cx); + } + Operation::DeleteThread { index } => { + let session_id = state.remove_thread(index); + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id, cx)); + }); + } + Operation::ToggleAgentPanel => { + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let panel_open = sidebar.read_with(cx, |sidebar, _cx| sidebar.agent_panel_visible); + workspace.update_in(cx, |workspace, window, cx| { + if panel_open { + workspace.close_panel::(window, cx); + } else { + workspace.open_panel::(window, cx); + } + }); + } + Operation::AddWorkspace => { + let path = state.next_workspace_path(); + state + .fs + .insert_tree( + &path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + let project = project::Project::test( + state.fs.clone() as Arc, + [path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + let new_index = state.workspace_paths.len(); + state.workspace_paths.push(path); + state.main_repo_indices.push(new_index); + } + Operation::OpenWorktreeAsWorkspace { worktree_index } => { + let worktree = state.unopened_worktrees.remove(worktree_index); + let project = project::Project::test( + state.fs.clone() as Arc, + [worktree.path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + state.workspace_paths.push(worktree.path); + } + Operation::RemoveWorkspace { index } => { + let removed = multi_workspace + .update_in(cx, |mw, window, cx| mw.remove_workspace(index, window, cx)); + if removed.is_some() { + state.workspace_paths.remove(index); + state.main_repo_indices.retain(|i| *i != index); + for i in &mut state.main_repo_indices { + if *i > index { + *i -= 1; + } + } + } + } + Operation::SwitchWorkspace { index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[index].clone()); + multi_workspace.update_in(cx, |mw, _window, cx| { + mw.activate(workspace, cx); + }); + } + Operation::AddLinkedWorktree { workspace_index } => { + let main_path = state.workspace_paths[workspace_index].clone(); + let dot_git = format!("{}/.git", main_path); + let worktree_name = state.next_worktree_name(); + let worktree_path = format!("/worktrees/{}", worktree_name); + + state.fs + .insert_tree( + &worktree_path, + serde_json::json!({ + ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name), + "src": {}, + }), + ) + .await; + + // Also create the worktree metadata dir inside the main repo's .git + state + .fs + .insert_tree( + &format!("{}/.git/worktrees/{}", main_path, worktree_name), + serde_json::json!({ + "commondir": "../../", + "HEAD": format!("ref: refs/heads/{}", worktree_name), + }), + ) + .await; + + let dot_git_path = std::path::Path::new(&dot_git); + let worktree_pathbuf = std::path::PathBuf::from(&worktree_path); + state + .fs + .with_git_state(dot_git_path, false, |git_state| { + git_state.worktrees.push(git::repository::Worktree { + path: worktree_pathbuf, + ref_name: Some(format!("refs/heads/{}", worktree_name).into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + // Re-scan the main workspace's project so it discovers the new worktree. + let main_workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone()); + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + state.unopened_worktrees.push(UnopenedWorktree { + path: worktree_path, + }); + } + } + } + + fn update_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.collapsed_groups.clear(); + let path_lists: Vec = sidebar + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { path_list, .. } => Some(path_list.clone()), + _ => None, + }) + .collect(); + for path_list in path_lists { + sidebar.expanded_groups.insert(path_list, 10_000); + } + sidebar.update_entries(cx); + }); + } + + fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?; + verify_all_threads_are_shown(sidebar, cx)?; + verify_active_state_matches_current_workspace(sidebar, cx)?; + Ok(()) + } + + fn verify_every_workspace_in_multiworkspace_is_shown( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let all_workspaces: HashSet = multi_workspace + .read(cx) + .workspaces() + .iter() + .map(|ws| ws.entity_id()) + .collect(); + + let sidebar_workspaces: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.workspace().map(|ws| ws.entity_id())) + .collect(); + + let stray = &sidebar_workspaces - &all_workspaces; + anyhow::ensure!( + stray.is_empty(), + "sidebar references workspaces not in multi-workspace: {:?}", + stray, + ); + + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + // A workspace may not appear directly in entries if another + // workspace in the same group is the representative. Check that + // every workspace is covered by a group that has at least one + // workspace visible in the sidebar entries. + let project_groups = ProjectGroupBuilder::from_multiworkspace(multi_workspace.read(cx), cx); + for ws in &workspaces { + if sidebar_workspaces.contains(&ws.entity_id()) { + continue; + } + let group_has_visible_member = project_groups.groups().any(|(_, group)| { + group.workspaces.contains(ws) + && group + .workspaces + .iter() + .any(|gws| sidebar_workspaces.contains(&gws.entity_id())) + }); + anyhow::ensure!( + group_has_visible_member, + "workspace {:?} (paths {:?}) is not in sidebar entries and no group member is visible", + ws.entity_id(), + workspace_path_list(ws, cx).paths(), + ); + } + Ok(()) + } + + fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let thread_store = SidebarThreadMetadataStore::global(cx); + + let sidebar_thread_ids: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.session_id().cloned()) + .collect(); + + let mut metadata_thread_ids: HashSet = HashSet::default(); + for workspace in &workspaces { + let path_list = workspace_path_list(workspace, cx); + if path_list.paths().is_empty() { + continue; + } + for metadata in thread_store.read(cx).entries_for_path(&path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + for snapshot in root_repository_snapshots(workspace, cx) { + for linked_worktree in snapshot.linked_worktrees() { + let worktree_path_list = + PathList::new(std::slice::from_ref(&linked_worktree.path)); + for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + } + } + } + + anyhow::ensure!( + sidebar_thread_ids == metadata_thread_ids, + "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}", + sidebar_thread_ids, + metadata_thread_ids, + ); + Ok(()) + } + + fn verify_active_state_matches_current_workspace( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let workspace = multi_workspace.read(cx).workspace(); + let panel_actually_visible = AgentPanel::is_visible(&workspace, cx); + let panel_active_session_id = + workspace + .read(cx) + .panel::(cx) + .and_then(|panel| { + panel + .read(cx) + .active_conversation_view() + .and_then(|cv| cv.read(cx).parent_id(cx)) + }); + + anyhow::ensure!( + sidebar.agent_panel_visible == panel_actually_visible, + "sidebar.agent_panel_visible ({}) does not match AgentPanel::is_visible ({})", + sidebar.agent_panel_visible, + panel_actually_visible, + ); + + // TODO: tighten this once focused_thread tracking is fixed + if sidebar.agent_panel_visible && !sidebar.active_thread_is_draft { + if let Some(panel_session_id) = panel_active_session_id { + anyhow::ensure!( + sidebar.focused_thread.as_ref() == Some(&panel_session_id), + "agent panel is visible with active session {:?} but sidebar focused_thread is {:?}", + panel_session_id, + sidebar.focused_thread, + ); + } + } + Ok(()) + } + + #[gpui::property_test] + async fn test_sidebar_invariants( + #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..20)] + raw_operations: Vec, + cx: &mut TestAppContext, + ) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = + project::Project::test(fs.clone() as Arc, ["/my-project".as_ref()], cx) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let mut state = TestState::new(fs, "/my-project".to_string()); + let mut executed: Vec = Vec::new(); + + for &raw_op in &raw_operations { + let operation = state.generate_operation(raw_op); + executed.push(format!("{:?}", operation)); + perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await; + cx.run_until_parked(); + + update_sidebar(&sidebar, cx); + cx.run_until_parked(); + + let result = + sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx)); + if let Err(err) = result { + let log = executed.join("\n "); + panic!( + "Property violation after step {}:\n{err}\n\nOperations:\n {log}", + executed.len(), + ); + } + } + } +}