sidebar: Add property tests (#52540)

Anthony Eid and Mikayla Maki created

## Context

<!-- What does this PR do, and why? How is it expected to impact users?
     Not just what changed, but what motivated it and why this approach.

Link to Linear issue (e.g., ENG-123) or GitHub issue (e.g., Closes #456)
     if one exists — helps with traceability. -->

## How to Review

<!-- Help reviewers focus their attention:
- For small PRs: note what to focus on (e.g., "error handling in
foo.rs")
- For large PRs (>400 LOC): provide a guided tour — numbered list of
files/commits to read in order. (The `large-pr` label is applied
automatically.)
     - See the review process guidelines for comment conventions -->

## Self-Review Checklist

<!-- Check before requesting review: -->
- [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 <mikayla.c.maki@gmail.com>

Change summary

crates/sidebar/src/project_group_builder.rs |   16 
crates/sidebar/src/sidebar.rs               | 4279 --------------------
crates/sidebar/src/sidebar_tests.rs         | 4664 +++++++++++++++++++++++
3 files changed, 4,705 insertions(+), 4,254 deletions(-)

Detailed changes

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<Workspace> {
+        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<Workspace>,
         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();

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<Entity<Workspace>> {
+        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<ThreadEntry> 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<Self>) {
-        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<std::path::PathBuf> = 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<Entity<Workspace>> = 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<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        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<project::Project> {
-        init_test(cx);
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
-            .await;
-        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-        project::Project::test(fs, [worktree_path.as_ref()], cx).await
-    }
-
-    fn setup_sidebar(
-        multi_workspace: &Entity<MultiWorkspace>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> Entity<Sidebar> {
-        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<Utc>,
-        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<Sidebar>, 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<Sidebar>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> Vec<String> {
-        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<project::Project> {
-        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| <dyn fs::Fs>::set_global(fs.clone(), cx));
-        project::Project::test(fs, [worktree_path.as_ref()], cx).await
-    }
-
-    fn add_agent_panel(
-        workspace: &Entity<Workspace>,
-        project: &Entity<project::Project>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> Entity<AgentPanel> {
-        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<MultiWorkspace>,
-        project: &Entity<project::Project>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
-        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| <dyn fs::Fs>::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<Sidebar>, 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::<String>::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::<std::path::PathBuf>(&[]);
-
-        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::<String>::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::<std::path::PathBuf>(&[]);
-
-        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<usize> 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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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<project::Project>, Arc<dyn fs::Fs>) {
-        init_test(cx);
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            worktree_path,
-            serde_json::json!({
-                ".git": {},
-                "src": {},
-            }),
-        )
-        .await;
-        cx.update(|cx| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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<Sidebar>| {
-            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("<none>");
-                        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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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]",
-            ]
-        );
-    }
-}

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<project::Project> {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
+        .await;
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    project::Project::test(fs, [worktree_path.as_ref()], cx).await
+}
+
+fn setup_sidebar(
+    multi_workspace: &Entity<MultiWorkspace>,
+    cx: &mut gpui::VisualTestContext,
+) -> Entity<Sidebar> {
+    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<Utc>,
+    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<Sidebar>, 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<Sidebar>,
+    cx: &mut gpui::VisualTestContext,
+) -> Vec<String> {
+    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<project::Project> {
+    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| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    project::Project::test(fs, [worktree_path.as_ref()], cx).await
+}
+
+fn add_agent_panel(
+    workspace: &Entity<Workspace>,
+    project: &Entity<project::Project>,
+    cx: &mut gpui::VisualTestContext,
+) -> Entity<AgentPanel> {
+    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<MultiWorkspace>,
+    project: &Entity<project::Project>,
+    cx: &mut gpui::VisualTestContext,
+) -> (Entity<Sidebar>, Entity<AgentPanel>) {
+    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| <dyn fs::Fs>::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<Sidebar>, 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::<String>::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::<std::path::PathBuf>(&[]);
+
+    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::<String>::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::<std::path::PathBuf>(&[]);
+
+    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<usize> 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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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<project::Project>, Arc<dyn fs::Fs>) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        worktree_path,
+        serde_json::json!({
+            ".git": {},
+            "src": {},
+        }),
+    )
+    .await;
+    cx.update(|cx| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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<Sidebar>| {
+        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("<none>");
+                    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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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<FakeFs>,
+        thread_counter: u32,
+        workspace_counter: u32,
+        worktree_counter: u32,
+        saved_thread_ids: Vec<acp::SessionId>,
+        workspace_paths: Vec<String>,
+        main_repo_indices: Vec<usize>,
+        unopened_worktrees: Vec<UnopenedWorktree>,
+    }
+
+    impl TestState {
+        fn new(fs: Arc<FakeFs>, 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<MultiWorkspace>,
+        sidebar: &Entity<Sidebar>,
+        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::<AgentPanel>(window, cx);
+                    } else {
+                        workspace.open_panel::<AgentPanel>(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<dyn fs::Fs>,
+                    [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<dyn fs::Fs>,
+                    [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<Sidebar>, cx: &mut gpui::VisualTestContext) {
+        sidebar.update_in(cx, |sidebar, _window, cx| {
+            sidebar.collapsed_groups.clear();
+            let path_lists: Vec<PathList> = 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<EntityId> = multi_workspace
+            .read(cx)
+            .workspaces()
+            .iter()
+            .map(|ws| ws.entity_id())
+            .collect();
+
+        let sidebar_workspaces: HashSet<EntityId> = 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<acp::SessionId> = sidebar
+            .contents
+            .entries
+            .iter()
+            .filter_map(|entry| entry.session_id().cloned())
+            .collect();
+
+        let mut metadata_thread_ids: HashSet<acp::SessionId> = 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::<AgentPanel>(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<u32>,
+        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| <dyn fs::Fs>::set_global(fs.clone(), cx));
+        let project =
+            project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/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<String> = 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(),
+                );
+            }
+        }
+    }
+}