Revert "Handle changing root paths without splitting in the sidebar" (#53579)

Mikayla Maki created

Reverts zed-industries/zed#53566

Change summary

crates/agent_ui/src/thread_metadata_store.rs  |   30 
crates/sidebar/src/sidebar.rs                 |  179 --
crates/sidebar/src/sidebar_tests.rs           | 1163 +++-----------------
crates/workspace/src/multi_workspace.rs       |  209 --
crates/workspace/src/multi_workspace_tests.rs |  154 ++
crates/workspace/src/workspace.rs             |    2 
6 files changed, 424 insertions(+), 1,313 deletions(-)

Detailed changes

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -477,36 +477,6 @@ impl ThreadMetadataStore {
         }
     }
 
-    pub fn update_main_worktree_paths(
-        &mut self,
-        old_paths: &PathList,
-        new_paths: PathList,
-        cx: &mut Context<Self>,
-    ) {
-        let session_ids = match self.threads_by_main_paths.remove(old_paths) {
-            Some(ids) if !ids.is_empty() => ids,
-            _ => return,
-        };
-
-        let new_index = self
-            .threads_by_main_paths
-            .entry(new_paths.clone())
-            .or_default();
-
-        for session_id in &session_ids {
-            new_index.insert(session_id.clone());
-
-            if let Some(thread) = self.threads.get_mut(session_id) {
-                thread.main_worktree_paths = new_paths.clone();
-                self.pending_thread_ops_tx
-                    .try_send(DbOperation::Upsert(thread.clone()))
-                    .log_err();
-            }
-        }
-
-        cx.notify();
-    }
-
     pub fn create_archived_worktree(
         &self,
         worktree_path: String,

crates/sidebar/src/sidebar.rs 🔗

@@ -283,8 +283,10 @@ impl ListEntry {
                 }
             }
             ListEntry::ProjectHeader { key, .. } => multi_workspace
-                .workspaces_for_project_group(key, cx)
+                .workspaces()
+                .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list())
                 .cloned()
+                .into_iter()
                 .collect(),
             ListEntry::ViewMore { .. } => Vec::new(),
         }
@@ -363,81 +365,35 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 ///
 /// For each path in the thread's `folder_paths`, produces a
 /// [`WorktreeInfo`] with a short display name, full path, and whether
-/// the worktree is the main checkout or a linked git worktree. When
-/// multiple main paths exist and a linked worktree's short name alone
-/// wouldn't identify which main project it belongs to, the main project
-/// name is prefixed for disambiguation (e.g. `project:feature`).
-///
-/// `linked_to_main` maps linked worktree abs paths to their main repo
-/// abs path, used to pick the correct prefix. Falls back to a heuristic
-/// when no mapping is available.
+/// the worktree is the main checkout or a linked git worktree.
 fn worktree_info_from_thread_paths(
     folder_paths: &PathList,
-    main_worktree_paths: &PathList,
-    linked_to_main: &HashMap<PathBuf, PathBuf>,
-) -> Vec<WorktreeInfo> {
-    let main_paths = main_worktree_paths.paths();
-
-    let mut infos: Vec<WorktreeInfo> = Vec::new();
-    let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
-
-    for path in folder_paths.paths().iter() {
+    group_key: &project::ProjectGroupKey,
+) -> impl Iterator<Item = WorktreeInfo> {
+    let main_paths = group_key.path_list().paths();
+    folder_paths.paths().iter().filter_map(|path| {
         let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path());
         if is_main {
-            let Some(name) = path.file_name() else {
-                continue;
-            };
-            infos.push(WorktreeInfo {
-                name: SharedString::from(name.to_string_lossy().to_string()),
+            let name = path.file_name()?.to_string_lossy().to_string();
+            Some(WorktreeInfo {
+                name: SharedString::from(name),
                 full_path: SharedString::from(path.display().to_string()),
                 highlight_positions: Vec::new(),
                 kind: ui::WorktreeKind::Main,
-            });
+            })
         } else {
-            let Some(main_path) = linked_to_main
-                .get(&**path)
-                .and_then(|main| main_paths.iter().find(|mp| mp.as_path() == main.as_path()))
-                .or_else(|| {
-                    main_paths
-                        .iter()
-                        .find(|mp| mp.file_name() == path.file_name())
-                        .or(main_paths.first())
-                })
-            else {
-                continue;
-            };
-            let short_name = linked_worktree_short_name(main_path, path).unwrap_or_default();
-            let project_name = main_path
-                .file_name()
-                .map(|n| SharedString::from(n.to_string_lossy().to_string()))
-                .unwrap_or_default();
-            linked_short_names.push((short_name.clone(), project_name));
-            infos.push(WorktreeInfo {
-                name: short_name,
+            let main_path = main_paths
+                .iter()
+                .find(|mp| mp.file_name() == path.file_name())
+                .or(main_paths.first())?;
+            Some(WorktreeInfo {
+                name: linked_worktree_short_name(main_path, path).unwrap_or_default(),
                 full_path: SharedString::from(path.display().to_string()),
                 highlight_positions: Vec::new(),
                 kind: ui::WorktreeKind::Linked,
-            });
-        }
-    }
-
-    // When the group has multiple main worktree paths and the thread's
-    // folder paths don't all share the same short name, prefix each
-    // linked worktree chip with its main project name so the user knows
-    // which project it belongs to.
-    let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name);
-
-    if main_paths.len() > 1 && !all_same_name {
-        for (info, (_short_name, project_name)) in infos
-            .iter_mut()
-            .filter(|i| i.kind == ui::WorktreeKind::Linked)
-            .zip(linked_short_names.iter())
-        {
-            info.name = SharedString::from(format!("{}:{}", project_name, info.name));
+            })
         }
-    }
-
-    infos
+    })
 }
 
 /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
@@ -524,16 +480,6 @@ impl Sidebar {
                 MultiWorkspaceEvent::WorkspaceRemoved(_) => {
                     this.update_entries(cx);
                 }
-                MultiWorkspaceEvent::ProjectGroupKeyChanged { old_key, new_key } => {
-                    ThreadMetadataStore::global(cx).update(cx, |store, cx| {
-                        store.update_main_worktree_paths(
-                            old_key.path_list(),
-                            new_key.path_list().clone(),
-                            cx,
-                        );
-                    });
-                    this.update_entries(cx);
-                }
             },
         )
         .detach();
@@ -966,21 +912,6 @@ impl Sidebar {
                 .as_ref()
                 .is_some_and(|active| group_workspaces.contains(active));
 
-            // Build a mapping from linked worktree paths to their main
-            // repo path, used to correctly attribute chips.
-            let linked_to_main: HashMap<PathBuf, PathBuf> = group_workspaces
-                .iter()
-                .flat_map(|ws| root_repository_snapshots(ws, cx))
-                .flat_map(|snapshot| {
-                    let main_path = snapshot.original_repo_abs_path.to_path_buf();
-                    snapshot
-                        .linked_worktrees()
-                        .iter()
-                        .map(move |wt| (wt.path.clone(), main_path.clone()))
-                        .collect::<Vec<_>>()
-                })
-                .collect();
-
             // Collect live thread infos from all workspaces in this group.
             let live_infos: Vec<_> = group_workspaces
                 .iter()
@@ -1018,28 +949,26 @@ impl Sidebar {
                 };
 
                 // Build a ThreadEntry from a metadata row.
-                let make_thread_entry =
-                    |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry {
-                        let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
-                        let worktrees = worktree_info_from_thread_paths(
-                            &row.folder_paths,
-                            &row.main_worktree_paths,
-                            &linked_to_main,
-                        );
-                        ThreadEntry {
-                            metadata: row,
-                            icon,
-                            icon_from_external_svg,
-                            status: AgentThreadStatus::default(),
-                            workspace,
-                            is_live: false,
-                            is_background: false,
-                            is_title_generating: false,
-                            highlight_positions: Vec::new(),
-                            worktrees,
-                            diff_stats: DiffStats::default(),
-                        }
-                    };
+                let make_thread_entry = |row: ThreadMetadata,
+                                         workspace: ThreadEntryWorkspace|
+                 -> ThreadEntry {
+                    let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
+                    let worktrees: Vec<WorktreeInfo> =
+                        worktree_info_from_thread_paths(&row.folder_paths, &group_key).collect();
+                    ThreadEntry {
+                        metadata: row,
+                        icon,
+                        icon_from_external_svg,
+                        status: AgentThreadStatus::default(),
+                        workspace,
+                        is_live: false,
+                        is_background: false,
+                        is_title_generating: false,
+                        highlight_positions: Vec::new(),
+                        worktrees,
+                        diff_stats: DiffStats::default(),
+                    }
+                };
 
                 // Main code path: one query per group via main_worktree_paths.
                 // The main_worktree_paths column is set on all new threads and
@@ -1255,17 +1184,11 @@ impl Sidebar {
                 if is_draft_for_group {
                     if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry {
                         let ws_path_list = workspace_path_list(draft_ws, cx);
-                        let main_worktree_paths =
-                            draft_ws.read(cx).project_group_key(cx).path_list().clone();
-                        let worktrees = worktree_info_from_thread_paths(
-                            &ws_path_list,
-                            &main_worktree_paths,
-                            &linked_to_main,
-                        );
+                        let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key);
                         entries.push(ListEntry::DraftThread {
                             key: group_key.clone(),
                             workspace: None,
-                            worktrees,
+                            worktrees: worktrees.collect(),
                         });
                     }
                 }
@@ -1289,14 +1212,9 @@ impl Sidebar {
                             continue;
                         }
                         let ws_path_list = workspace_path_list(ws, cx);
-                        let ws_main_paths = ws.read(cx).project_group_key(cx).path_list().clone();
-                        let has_linked_worktrees = worktree_info_from_thread_paths(
-                            &ws_path_list,
-                            &ws_main_paths,
-                            &linked_to_main,
-                        )
-                        .iter()
-                        .any(|wt| wt.kind == ui::WorktreeKind::Linked);
+                        let has_linked_worktrees =
+                            worktree_info_from_thread_paths(&ws_path_list, &group_key)
+                                .any(|wt| wt.kind == ui::WorktreeKind::Linked);
                         if !has_linked_worktrees {
                             continue;
                         }
@@ -1309,11 +1227,8 @@ impl Sidebar {
                         if has_threads {
                             continue;
                         }
-                        let worktrees = worktree_info_from_thread_paths(
-                            &ws_path_list,
-                            &ws_main_paths,
-                            &linked_to_main,
-                        );
+                        let worktrees: Vec<WorktreeInfo> =
+                            worktree_info_from_thread_paths(&ws_path_list, &group_key).collect();
 
                         entries.push(ListEntry::DraftThread {
                             key: group_key.clone(),

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -251,32 +251,6 @@ fn save_thread_metadata(
     cx.run_until_parked();
 }
 
-fn save_thread_metadata_with_main_paths(
-    session_id: &str,
-    title: &str,
-    folder_paths: PathList,
-    main_worktree_paths: PathList,
-    cx: &mut TestAppContext,
-) {
-    let session_id = acp::SessionId::new(Arc::from(session_id));
-    let title = SharedString::from(title.to_string());
-    let updated_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap();
-    let metadata = ThreadMetadata {
-        session_id,
-        agent_id: agent::ZED_AGENT_ID.clone(),
-        title,
-        updated_at,
-        created_at: None,
-        folder_paths,
-        main_worktree_paths,
-        archived: false,
-    };
-    cx.update(|cx| {
-        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
-    });
-    cx.run_until_parked();
-}
-
 fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
     sidebar.update_in(cx, |_, window, cx| {
         cx.focus_self(window);
@@ -348,11 +322,6 @@ fn visible_entries_as_strings(
                 } else {
                     ""
                 };
-                let is_active = sidebar
-                    .active_entry
-                    .as_ref()
-                    .is_some_and(|active| active.matches_entry(entry));
-                let active_indicator = if is_active { " (active)" } else { "" };
                 match entry {
                     ListEntry::ProjectHeader {
                         label,
@@ -369,7 +338,7 @@ fn visible_entries_as_strings(
                     }
                     ListEntry::Thread(thread) => {
                         let title = thread.metadata.title.as_ref();
-                        let live = if thread.is_live { " *" } else { "" };
+                        let active = if thread.is_live { " *" } else { "" };
                         let status_str = match thread.status {
                             AgentThreadStatus::Running => " (running)",
                             AgentThreadStatus::Error => " (error)",
@@ -385,7 +354,7 @@ fn visible_entries_as_strings(
                             ""
                         };
                         let worktree = format_linked_worktree_chips(&thread.worktrees);
-                        format!("  {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}")
+                        format!("  {title}{worktree}{active}{status_str}{notified}{selected}")
                     }
                     ListEntry::ViewMore {
                         is_fully_expanded, ..
@@ -405,7 +374,7 @@ fn visible_entries_as_strings(
                         if workspace.is_some() {
                             format!("  [+ New Thread{}]{}", worktree, selected)
                         } else {
-                            format!("  [~ Draft{}]{}{}", worktree, active_indicator, selected)
+                            format!("  [~ Draft{}]{}", worktree, selected)
                         }
                     }
                 }
@@ -574,10 +543,7 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-        ]
+        vec!["v [my-project]"]
     );
 }
 
@@ -613,7 +579,6 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Fix crash in project panel",
             "  Add inline diff view",
@@ -644,11 +609,7 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a]",
-            "  Thread A1",
-        ]
+        vec!["v [project-a]", "  Thread A1"]
     );
 
     // Add a second workspace
@@ -659,11 +620,7 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a]",
-            "  Thread A1",
-        ]
+        vec!["v [project-a]", "  Thread A1",]
     );
 }
 
@@ -682,7 +639,6 @@ async fn test_view_more_pagination(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Thread 12",
             "  Thread 11",
@@ -793,11 +749,7 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Thread 1",
-        ]
+        vec!["v [my-project]", "  Thread 1"]
     );
 
     // Collapse
@@ -808,10 +760,7 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "> [my-project]",
-        ]
+        vec!["> [my-project]"]
     );
 
     // Expand
@@ -822,11 +771,7 @@ async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Thread 1",
-        ]
+        vec!["v [my-project]", "  Thread 1"]
     );
 }
 
@@ -996,7 +941,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [expanded-project]",
             "  Completed thread",
             "  Running thread * (running)  <== selected",
@@ -1160,14 +1104,10 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Thread 1",
-        ]
+        vec!["v [my-project]", "  Thread 1"]
     );
 
-    // Focus the sidebar and select the header
+    // Focus the sidebar and select the header (index 0)
     focus_sidebar(&sidebar, cx);
     sidebar.update_in(cx, |sidebar, _window, _cx| {
         sidebar.selection = Some(0);
@@ -1179,10 +1119,7 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "> [my-project]  <== selected",
-        ]
+        vec!["> [my-project]  <== selected"]
     );
 
     // Confirm again expands the group
@@ -1191,11 +1128,7 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]  <== selected",
-            "  Thread 1",
-        ]
+        vec!["v [my-project]  <== selected", "  Thread 1",]
     );
 }
 
@@ -1246,11 +1179,7 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Thread 1",
-        ]
+        vec!["v [my-project]", "  Thread 1"]
     );
 
     // Focus sidebar and manually select the header (index 0). Press left to collapse.
@@ -1264,10 +1193,7 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "> [my-project]  <== selected",
-        ]
+        vec!["> [my-project]  <== selected"]
     );
 
     // Press right to expand
@@ -1276,11 +1202,7 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]  <== selected",
-            "  Thread 1",
-        ]
+        vec!["v [my-project]  <== selected", "  Thread 1",]
     );
 
     // Press right again on already-expanded header moves selection down
@@ -1307,11 +1229,7 @@ async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContex
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Thread 1  <== selected",
-        ]
+        vec!["v [my-project]", "  Thread 1  <== selected",]
     );
 
     // Pressing left on a child collapses the parent group and selects it
@@ -1321,10 +1239,7 @@ async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContex
     assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "> [my-project]  <== selected",
-        ]
+        vec!["> [my-project]  <== selected"]
     );
 }
 
@@ -1338,10 +1253,7 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
     // An empty project has only the header.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [empty-project]",
-        ]
+        vec!["v [empty-project]"]
     );
 
     // Focus sidebar — focus_in does not set a selection
@@ -1473,12 +1385,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
     entries[1..].sort();
     assert_eq!(
         entries,
-        vec![
-            //
-            "v [my-project]",
-            "  Hello * (active)",
-            "  Hello * (running)",
-        ]
+        vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
     );
 }
 
@@ -1571,11 +1478,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
     // Thread A is still running; no notification yet.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a]",
-            "  Hello * (running) (active)",
-        ]
+        vec!["v [project-a]", "  Hello * (running)",]
     );
 
     // Complete thread A's turn (transition Running → Completed).
@@ -1585,11 +1488,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
     // The completed background thread shows a notification indicator.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a]",
-            "  Hello * (!) (active)",
-        ]
+        vec!["v [project-a]", "  Hello * (!)",]
     );
 }
 
@@ -1629,7 +1528,6 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Fix crash in project panel",
             "  Add inline diff view",
@@ -1642,11 +1540,7 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
     type_in_search(&sidebar, "diff", cx);
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Add inline diff view  <== selected",
-        ]
+        vec!["v [my-project]", "  Add inline diff view  <== selected",]
     );
 
     // User changes query to something with no matches — list is empty.
@@ -1681,7 +1575,6 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Fix Crash In Project Panel  <== selected",
         ]
@@ -1692,7 +1585,6 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Fix Crash In Project Panel  <== selected",
         ]
@@ -1723,12 +1615,7 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
     // Confirm the full list is showing.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Alpha thread",
-            "  Beta thread",
-        ]
+        vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
     );
 
     // User types a search query to filter down.
@@ -1736,11 +1623,7 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
     type_in_search(&sidebar, "alpha", cx);
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Alpha thread  <== selected",
-        ]
+        vec!["v [my-project]", "  Alpha thread  <== selected",]
     );
 
     // User presses Escape — filter clears, full list is restored.
@@ -1750,7 +1633,6 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Alpha thread  <== selected",
             "  Beta thread",
@@ -1807,7 +1689,6 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [project-a]",
             "  Fix bug in sidebar",
             "  Add tests for editor",
@@ -1818,11 +1699,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
     type_in_search(&sidebar, "sidebar", cx);
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a]",
-            "  Fix bug in sidebar  <== selected",
-        ]
+        vec!["v [project-a]", "  Fix bug in sidebar  <== selected",]
     );
 
     // "typo" only matches in the second workspace — the first header disappears.
@@ -1838,7 +1715,6 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [project-a]",
             "  Fix bug in sidebar  <== selected",
             "  Add tests for editor",
@@ -1898,7 +1774,6 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [alpha-project]",
             "  Fix bug in sidebar  <== selected",
             "  Add tests for editor",
@@ -1910,11 +1785,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
     type_in_search(&sidebar, "sidebar", cx);
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [alpha-project]",
-            "  Fix bug in sidebar  <== selected",
-        ]
+        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
@@ -1924,11 +1795,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
     type_in_search(&sidebar, "fix", cx);
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [alpha-project]",
-            "  Fix bug in sidebar  <== selected",
-        ]
+        vec!["v [alpha-project]", "  Fix bug in sidebar  <== selected",]
     );
 
     // A query that matches a workspace name AND a thread in that same workspace.
@@ -1937,7 +1804,6 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [alpha-project]",
             "  Fix bug in sidebar  <== selected",
             "  Add tests for editor",
@@ -1951,7 +1817,6 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [alpha-project]",
             "  Fix bug in sidebar  <== selected",
             "  Add tests for editor",
@@ -2001,11 +1866,7 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
     let filtered = visible_entries_as_strings(&sidebar, cx);
     assert_eq!(
         filtered,
-        vec![
-            //
-            "v [my-project]",
-            "  Hidden gem thread  <== selected",
-        ]
+        vec!["v [my-project]", "  Hidden gem thread  <== selected",]
     );
     assert!(
         !filtered.iter().any(|e| e.contains("View More")),
@@ -2041,21 +1902,14 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "> [my-project]  <== selected",
-        ]
+        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",
-        ]
+        vec!["> [my-project]", "  Important thread  <== selected",]
     );
 }
 
@@ -2089,7 +1943,6 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Fix crash in panel  <== selected",
             "  Fix lint warnings",
@@ -2102,7 +1955,6 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Fix crash in panel",
             "  Fix lint warnings  <== selected",
@@ -2114,7 +1966,6 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
         vec![
-            //
             "v [my-project]",
             "  Fix crash in panel  <== selected",
             "  Fix lint warnings",
@@ -2155,11 +2006,7 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Historical Thread",
-        ]
+        vec!["v [my-project]", "  Historical Thread",]
     );
 
     // Switch to workspace 1 so we can verify the confirm switches back.
@@ -2220,12 +2067,7 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Thread A",
-            "  Thread B",
-        ]
+        vec!["v [my-project]", "  Thread A", "  Thread B",]
     );
 
     // Keyboard confirm preserves selection.
@@ -2277,11 +2119,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Hello * (active)",
-        ]
+        vec!["v [my-project]", "  Hello *"]
     );
 
     // Simulate the agent generating a title. The notification chain is:
@@ -2303,11 +2141,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [my-project]",
-            "  Friendly Greeting with AI * (active)",
-        ]
+        vec!["v [my-project]", "  Friendly Greeting with AI *"]
     );
 }
 
@@ -2449,825 +2283,186 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
     sidebar.read_with(cx, |sidebar, _cx| {
         assert_active_thread(
             sidebar,
-            &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, &project_b, 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_active_thread(
-            sidebar,
-            &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_active_thread(
-            sidebar,
-            &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| {
-        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
-        if let Some(workspace) = workspace {
-            mw.activate(workspace, window, cx);
-        }
-    });
-    cx.run_until_parked();
-
-    sidebar.read_with(cx, |sidebar, _cx| {
-        assert_active_thread(
-            sidebar,
-            &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_active_thread(
-            sidebar,
-            &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, cx);
-
-    // 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, &project, 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 * (active)",
-        ]
-    );
-
-    // 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!(
-            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
-            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
-            sidebar.active_entry,
-        );
-    });
-
-    // 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 active
-    // thread's metadata was re-saved with the new paths by the agent panel's
-    // project subscription. The old [project-a] key is replaced by the new
-    // key since no other workspace claims it.
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a, project-b]",
-            "  Hello * (active)",
-        ]
-    );
-
-    // 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!(
-            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
-            "After adding a folder the panel still has a thread with messages, \
-                 so active_entry should be Thread, got {:?}",
-            sidebar.active_entry,
-        );
-    });
-
-    // 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_active_draft(
-            sidebar,
-            &workspace,
-            "After creating a new thread active_entry should be Draft",
-        );
-    });
-}
-
-#[gpui::test]
-async fn test_worktree_add_and_remove_migrates_threads(cx: &mut TestAppContext) {
-    // When a worktree is added to a project, the project group key changes
-    // and all historical threads should be migrated to the new key. Removing
-    // the worktree should migrate them back.
-    let (_fs, project) = init_multi_project_test(&["/project-a", "/project-b"], 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 two threads against the initial project group [/project-a].
-    save_n_test_threads(2, &project, cx).await;
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
-    cx.run_until_parked();
-
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a]",
-            "  Thread 2",
-            "  Thread 1",
-        ]
-    );
-
-    // Verify the metadata store has threads under the old key.
-    let old_key_paths = PathList::new(&[PathBuf::from("/project-a")]);
-    cx.update(|_window, cx| {
-        let store = ThreadMetadataStore::global(cx).read(cx);
-        assert_eq!(
-            store.entries_for_main_worktree_path(&old_key_paths).count(),
-            2,
-            "should have 2 threads under old key before add"
-        );
-    });
-
-    // Add a second worktree to the same project.
-    project
-        .update(cx, |project, cx| {
-            project.find_or_create_worktree("/project-b", true, cx)
-        })
-        .await
-        .expect("should add worktree");
-    cx.run_until_parked();
-
-    // The project group key should now be [/project-a, /project-b].
-    let new_key_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
-
-    // Verify multi-workspace state: exactly one project group key, the new one.
-    multi_workspace.read_with(cx, |mw, _cx| {
-        let keys: Vec<_> = mw.project_group_keys().cloned().collect();
-        assert_eq!(
-            keys.len(),
-            1,
-            "should have exactly 1 project group key after add"
-        );
-        assert_eq!(
-            keys[0].path_list(),
-            &new_key_paths,
-            "the key should be the new combined path list"
-        );
-    });
-
-    // Verify threads were migrated to the new key.
-    cx.update(|_window, cx| {
-        let store = ThreadMetadataStore::global(cx).read(cx);
-        assert_eq!(
-            store.entries_for_main_worktree_path(&old_key_paths).count(),
-            0,
-            "should have 0 threads under old key after migration"
-        );
-        assert_eq!(
-            store.entries_for_main_worktree_path(&new_key_paths).count(),
-            2,
-            "should have 2 threads under new key after migration"
-        );
-    });
-
-    // Sidebar should show threads under the new header.
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
-    cx.run_until_parked();
-
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a, project-b]",
-            "  Thread 2",
-            "  Thread 1",
-        ]
-    );
-
-    // Now remove the second worktree.
-    let worktree_id = project.read_with(cx, |project, cx| {
-        project
-            .visible_worktrees(cx)
-            .find(|wt| wt.read(cx).abs_path().as_ref() == Path::new("/project-b"))
-            .map(|wt| wt.read(cx).id())
-            .expect("should find project-b worktree")
-    });
-    project.update(cx, |project, cx| {
-        project.remove_worktree(worktree_id, cx);
-    });
-    cx.run_until_parked();
-
-    // The key should revert to [/project-a].
-    multi_workspace.read_with(cx, |mw, _cx| {
-        let keys: Vec<_> = mw.project_group_keys().cloned().collect();
-        assert_eq!(
-            keys.len(),
-            1,
-            "should have exactly 1 project group key after remove"
-        );
-        assert_eq!(
-            keys[0].path_list(),
-            &old_key_paths,
-            "the key should revert to the original path list"
-        );
-    });
-
-    // Threads should be migrated back to the old key.
-    cx.update(|_window, cx| {
-        let store = ThreadMetadataStore::global(cx).read(cx);
-        assert_eq!(
-            store.entries_for_main_worktree_path(&new_key_paths).count(),
-            0,
-            "should have 0 threads under new key after revert"
-        );
-        assert_eq!(
-            store.entries_for_main_worktree_path(&old_key_paths).count(),
-            2,
-            "should have 2 threads under old key after revert"
-        );
-    });
-
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
-    cx.run_until_parked();
-
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a]",
-            "  Thread 2",
-            "  Thread 1",
-        ]
-    );
-}
-
-#[gpui::test]
-async fn test_worktree_add_key_collision_removes_duplicate_workspace(cx: &mut TestAppContext) {
-    // When a worktree is added to workspace A and the resulting key matches
-    // an existing workspace B's key (and B has the same root paths), B
-    // should be removed as a true duplicate.
-    let (fs, project_a) = init_multi_project_test(&["/project-a", "/project-b"], cx).await;
-    let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
-    let sidebar = setup_sidebar(&multi_workspace, cx);
-
-    // Save a thread against workspace A [/project-a].
-    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
-
-    // Create workspace B with both worktrees [/project-a, /project-b].
-    let project_b = project::Project::test(
-        fs.clone() as Arc<dyn Fs>,
-        ["/project-a".as_ref(), "/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)
-    });
-    cx.run_until_parked();
-
-    // Switch back to workspace A so it's the active workspace when the collision happens.
-    let workspace_a =
-        multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
-    multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate(workspace_a, window, cx);
-    });
-    cx.run_until_parked();
-
-    // Save a thread against workspace B [/project-a, /project-b].
-    save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
-
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
-    cx.run_until_parked();
-
-    // Both project groups should be visible.
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a, project-b]",
-            "  Thread B",
-            "v [project-a]",
-            "  Thread A",
-        ]
-    );
-
-    let workspace_b_id = workspace_b.entity_id();
-
-    // Now add /project-b to workspace A's project, causing a key collision.
-    project_a
-        .update(cx, |project, cx| {
-            project.find_or_create_worktree("/project-b", true, cx)
-        })
-        .await
-        .expect("should add worktree");
-    cx.run_until_parked();
-
-    // Workspace B should have been removed (true duplicate — same root paths).
-    multi_workspace.read_with(cx, |mw, _cx| {
-        let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
-        assert!(
-            !workspace_ids.contains(&workspace_b_id),
-            "workspace B should have been removed after key collision"
-        );
-    });
-
-    // There should be exactly one project group key now.
-    let combined_paths = PathList::new(&[PathBuf::from("/project-a"), PathBuf::from("/project-b")]);
-    multi_workspace.read_with(cx, |mw, _cx| {
-        let keys: Vec<_> = mw.project_group_keys().cloned().collect();
-        assert_eq!(
-            keys.len(),
-            1,
-            "should have exactly 1 project group key after collision"
-        );
-        assert_eq!(
-            keys[0].path_list(),
-            &combined_paths,
-            "the remaining key should be the combined paths"
-        );
-    });
-
-    // Both threads should be visible under the merged group.
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
-    cx.run_until_parked();
-
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a, project-b]",
-            "  Thread A",
-            "  Thread B",
-        ]
-    );
-}
-
-#[gpui::test]
-async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) {
-    // When workspace A adds a folder that makes it collide with workspace B,
-    // and B is the *active* workspace, A (the incoming one) should be
-    // dropped so the user stays on B. A linked worktree sibling of A
-    // should migrate into B's group.
-    init_test(cx);
-    let fs = FakeFs::new(cx.executor());
-
-    // Set up /project-a with a linked worktree.
-    fs.insert_tree(
-        "/project-a",
-        serde_json::json!({
-            ".git": {
-                "worktrees": {
-                    "feature": {
-                        "commondir": "../../",
-                        "HEAD": "ref: refs/heads/feature",
-                    },
-                },
-            },
-            "src": {},
-        }),
-    )
-    .await;
-    fs.insert_tree(
-        "/wt-feature",
-        serde_json::json!({
-            ".git": "gitdir: /project-a/.git/worktrees/feature",
-            "src": {},
-        }),
-    )
-    .await;
-    fs.add_linked_worktree_for_repo(
-        Path::new("/project-a/.git"),
-        false,
-        git::repository::Worktree {
-            path: PathBuf::from("/wt-feature"),
-            ref_name: Some("refs/heads/feature".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        },
-    )
-    .await;
-    fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "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;
-    project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
-
-    // Linked worktree sibling of A.
-    let project_wt = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await;
-    project_wt
-        .update(cx, |p, cx| p.git_scans_complete(cx))
-        .await;
-
-    // Workspace B has both folders already.
-    let project_b = project::Project::test(
-        fs.clone() as Arc<dyn Fs>,
-        ["/project-a".as_ref(), "/project-b".as_ref()],
-        cx,
-    )
-    .await;
-
-    let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
-    let sidebar = setup_sidebar(&multi_workspace, cx);
-
-    // Add agent panels to all workspaces.
-    let workspace_a_entity = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-    add_agent_panel(&workspace_a_entity, cx);
-
-    // Add the linked worktree workspace (sibling of A).
-    let workspace_wt = multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.test_add_workspace(project_wt.clone(), window, cx)
-    });
-    add_agent_panel(&workspace_wt, cx);
-    cx.run_until_parked();
-
-    // Add workspace B (will become active).
-    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.test_add_workspace(project_b.clone(), window, cx)
-    });
-    add_agent_panel(&workspace_b, cx);
-    cx.run_until_parked();
-
-    // Save threads in each group.
-    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
-    save_thread_metadata_with_main_paths(
-        "thread-wt",
-        "Worktree Thread",
-        PathList::new(&[PathBuf::from("/wt-feature")]),
-        PathList::new(&[PathBuf::from("/project-a")]),
-        cx,
-    );
-    save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
-
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
-    cx.run_until_parked();
-
-    // B is active, A and wt-feature are in one group, B in another.
-    assert_eq!(
-        multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()),
-        workspace_b.entity_id(),
-        "workspace B should be active"
-    );
-    multi_workspace.read_with(cx, |mw, _cx| {
-        assert_eq!(mw.project_group_keys().count(), 2, "should have 2 groups");
-        assert_eq!(mw.workspaces().count(), 3, "should have 3 workspaces");
-    });
-
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a, project-b]",
-            "  [~ Draft] (active)",
-            "  Thread B",
-            "v [project-a]",
-            "  Thread A",
-            "  Worktree Thread {wt-feature}",
-        ]
-    );
-
-    let workspace_a = multi_workspace.read_with(cx, |mw, _| {
-        mw.workspaces()
-            .find(|ws| {
-                ws.entity_id() != workspace_b.entity_id()
-                    && ws.entity_id() != workspace_wt.entity_id()
-            })
-            .unwrap()
-            .clone()
-    });
-
-    // Add /project-b to workspace A's project, causing a collision with B.
-    project_a
-        .update(cx, |project, cx| {
-            project.find_or_create_worktree("/project-b", true, cx)
-        })
-        .await
-        .expect("should add worktree");
-    cx.run_until_parked();
-
-    // Workspace A (the incoming duplicate) should have been dropped.
-    multi_workspace.read_with(cx, |mw, _cx| {
-        let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
-        assert!(
-            !workspace_ids.contains(&workspace_a.entity_id()),
-            "workspace A should have been dropped"
-        );
-    });
-
-    // The active workspace should still be B.
-    assert_eq!(
-        multi_workspace.read_with(cx, |mw, _| mw.workspace().entity_id()),
-        workspace_b.entity_id(),
-        "workspace B should still be active"
-    );
-
-    // The linked worktree sibling should have migrated into B's group
-    // (it got the folder add and now shares the same key).
-    multi_workspace.read_with(cx, |mw, _cx| {
-        let workspace_ids: Vec<_> = mw.workspaces().map(|ws| ws.entity_id()).collect();
-        assert!(
-            workspace_ids.contains(&workspace_wt.entity_id()),
-            "linked worktree workspace should still exist"
-        );
-        assert_eq!(
-            mw.project_group_keys().count(),
-            1,
-            "should have 1 group after merge"
-        );
-        assert_eq!(
-            mw.workspaces().count(),
-            2,
-            "should have 2 workspaces (B + linked worktree)"
-        );
-    });
-
-    // The linked worktree workspace should have gotten the new folder.
-    let wt_worktree_count =
-        project_wt.read_with(cx, |project, cx| project.visible_worktrees(cx).count());
-    assert_eq!(
-        wt_worktree_count, 2,
-        "linked worktree project should have gotten /project-b"
-    );
-
-    // After: everything merged under one group. Thread A migrated,
-    // worktree thread shows its chip, B's thread and draft remain.
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
-    cx.run_until_parked();
-
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project-a, project-b]",
-            "  [~ Draft] (active)",
-            "  [+ New Thread {project-a:wt-feature}]",
-            "  Thread A",
-            "  Worktree Thread {project-a:wt-feature}",
-            "  Thread B",
-        ]
-    );
-}
-
-#[gpui::test]
-async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext) {
-    // When a worktree is added to the main workspace, a linked worktree
-    // sibling (different root paths, same project group key) should also
-    // get the new folder added to its project.
-    init_test(cx);
-    let fs = FakeFs::new(cx.executor());
-
-    fs.insert_tree(
-        "/project",
-        serde_json::json!({
-            ".git": {
-                "worktrees": {
-                    "feature": {
-                        "commondir": "../../",
-                        "HEAD": "ref: refs/heads/feature",
-                    },
-                },
-            },
-            "src": {},
-        }),
-    )
-    .await;
-
-    fs.insert_tree(
-        "/wt-feature",
-        serde_json::json!({
-            ".git": "gitdir: /project/.git/worktrees/feature",
-            "src": {},
-        }),
-    )
-    .await;
-
-    fs.add_linked_worktree_for_repo(
-        Path::new("/project/.git"),
-        false,
-        git::repository::Worktree {
-            path: PathBuf::from("/wt-feature"),
-            ref_name: Some("refs/heads/feature".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        },
-    )
-    .await;
-
-    // Create a second independent project to add as a folder later.
-    fs.insert_tree(
-        "/other-project",
-        serde_json::json!({ ".git": {}, "src": {} }),
-    )
-    .await;
-
-    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+            &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 main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
-    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await;
+    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, &project_b, cx).await;
+    cx.run_until_parked();
 
-    main_project
-        .update(cx, |p, cx| p.git_scans_complete(cx))
-        .await;
-    worktree_project
-        .update(cx, |p, cx| p.git_scans_complete(cx))
-        .await;
+    // 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_active_thread(
+            sidebar,
+            &session_id_a,
+            "Opening a thread in a non-active panel should not change focused_thread",
+        );
+    });
 
-    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);
+    workspace_b.update_in(cx, |workspace, window, cx| {
+        workspace.focus_handle(cx).focus(window, cx);
+    });
+    cx.run_until_parked();
 
-    // Add agent panel to the main workspace.
-    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-    add_agent_panel(&main_workspace, cx);
+    sidebar.read_with(cx, |sidebar, _cx| {
+        assert_active_thread(
+            sidebar,
+            &session_id_a,
+            "Defocusing the sidebar should not change focused_thread",
+        );
+    });
 
-    // Open the linked worktree as a separate workspace.
-    let wt_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.test_add_workspace(worktree_project.clone(), window, cx)
+    // Switching workspaces via the multi_workspace (simulates clicking
+    // a workspace header) should clear focused_thread.
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
+        if let Some(workspace) = workspace {
+            mw.activate(workspace, window, cx);
+        }
     });
-    add_agent_panel(&wt_workspace, cx);
     cx.run_until_parked();
 
-    // Both workspaces should share the same project group key [/project].
-    multi_workspace.read_with(cx, |mw, _cx| {
-        assert_eq!(
-            mw.project_group_keys().count(),
-            1,
-            "should have 1 project group key before add"
+    sidebar.read_with(cx, |sidebar, _cx| {
+        assert_active_thread(
+            sidebar,
+            &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"
         );
-        assert_eq!(mw.workspaces().count(), 2, "should have 2 workspaces");
     });
 
-    // Save threads against each workspace.
-    save_named_thread_metadata("main-thread", "Main Thread", &main_project, cx).await;
-    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
+    // ── 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();
 
-    // Verify both threads are under the old key [/project].
-    let old_key_paths = PathList::new(&[PathBuf::from("/project")]);
-    cx.update(|_window, cx| {
-        let store = ThreadMetadataStore::global(cx).read(cx);
-        assert_eq!(
-            store.entries_for_main_worktree_path(&old_key_paths).count(),
-            2,
-            "should have 2 threads under old key before add"
+    sidebar.read_with(cx, |sidebar, _cx| {
+        assert_active_thread(
+            sidebar,
+            &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, cx);
 
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+    // 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, &project, cx).await;
     cx.run_until_parked();
 
+    // Verify the thread appears in the sidebar.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [project]",
-            "  [~ Draft {wt-feature}] (active)",
-            "  Worktree Thread {wt-feature}",
-            "  Main Thread",
-        ]
+        vec!["v [project-a]", "  Hello *",]
     );
 
-    // Add /other-project as a folder to the main workspace.
-    main_project
+    // 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!(
+            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
+            "Panel has a thread with messages, so active_entry should be Thread, got {:?}",
+            sidebar.active_entry,
+        );
+    });
+
+    // 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("/other-project", true, cx)
+            project.find_or_create_worktree("/project-b", true, cx)
         })
         .await
         .expect("should add worktree");
     cx.run_until_parked();
 
-    // The linked worktree workspace should have gotten the new folder too.
-    let wt_worktree_count =
-        worktree_project.read_with(cx, |project, cx| project.visible_worktrees(cx).count());
+    // The workspace path_list is now [project-a, project-b]. The active
+    // thread's metadata was re-saved with the new paths by the agent panel's
+    // project subscription, so it stays visible under the updated group.
+    // The old [project-a] group persists in the sidebar (empty) because
+    // project_group_keys is append-only.
     assert_eq!(
-        wt_worktree_count, 2,
-        "linked worktree project should have gotten the new folder"
+        visible_entries_as_strings(&sidebar, cx),
+        vec![
+            "v [project-a, project-b]", //
+            "  Hello *",
+            "v [project-a]",
+        ]
     );
 
-    // Both workspaces should still exist under one key.
-    multi_workspace.read_with(cx, |mw, _cx| {
-        assert_eq!(mw.workspaces().count(), 2, "both workspaces should survive");
-        assert_eq!(
-            mw.project_group_keys().count(),
-            1,
-            "should still have 1 project group key"
+    // 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!(
+            matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
+            "After adding a folder the panel still has a thread with messages, \
+                 so active_entry should be Thread, got {:?}",
+            sidebar.active_entry,
         );
     });
 
-    // Threads should have been migrated to the new key.
-    let new_key_paths =
-        PathList::new(&[PathBuf::from("/other-project"), PathBuf::from("/project")]);
-    cx.update(|_window, cx| {
-        let store = ThreadMetadataStore::global(cx).read(cx);
-        assert_eq!(
-            store.entries_for_main_worktree_path(&old_key_paths).count(),
-            0,
-            "should have 0 threads under old key after migration"
-        );
-        assert_eq!(
-            store.entries_for_main_worktree_path(&new_key_paths).count(),
-            2,
-            "should have 2 threads under new key after migration"
-        );
+    // 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);
     });
-
-    // Both threads should still be visible in the sidebar.
-    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
     cx.run_until_parked();
 
-    assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            //
-            "v [other-project, project]",
-            "  [~ Draft {project:wt-feature}] (active)",
-            "  Worktree Thread {project:wt-feature}",
-            "  Main Thread",
-        ]
-    );
+    // 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_active_draft(
+            sidebar,
+            &workspace,
+            "After creating a new thread active_entry should be Draft",
+        );
+    });
 }
 
 #[gpui::test]

crates/workspace/src/multi_workspace.rs 🔗

@@ -101,10 +101,6 @@ pub enum MultiWorkspaceEvent {
     ActiveWorkspaceChanged,
     WorkspaceAdded(Entity<Workspace>),
     WorkspaceRemoved(EntityId),
-    ProjectGroupKeyChanged {
-        old_key: ProjectGroupKey,
-        new_key: ProjectGroupKey,
-    },
 }
 
 pub enum SidebarEvent {
@@ -306,7 +302,7 @@ pub struct MultiWorkspace {
     workspaces: Vec<Entity<Workspace>>,
     active_workspace: ActiveWorkspace,
     project_group_keys: Vec<ProjectGroupKey>,
-    workspace_group_keys: HashMap<EntityId, ProjectGroupKey>,
+    provisional_project_group_keys: HashMap<EntityId, ProjectGroupKey>,
     sidebar: Option<Box<dyn SidebarHandle>>,
     sidebar_open: bool,
     sidebar_overlay: Option<AnyView>,
@@ -359,7 +355,7 @@ impl MultiWorkspace {
         Self {
             window_id: window.window_handle().window_id(),
             project_group_keys: Vec::new(),
-            workspace_group_keys: HashMap::default(),
+            provisional_project_group_keys: HashMap::default(),
             workspaces: Vec::new(),
             active_workspace: ActiveWorkspace::Transient(workspace),
             sidebar: None,
@@ -563,11 +559,19 @@ impl MultiWorkspace {
         cx.subscribe_in(&project, window, {
             let workspace = workspace.downgrade();
             move |this, _project, event, _window, cx| match event {
-                project::Event::WorktreeAdded(_)
-                | project::Event::WorktreeRemoved(_)
-                | project::Event::WorktreeUpdatedRootRepoCommonDir(_) => {
+                project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
                     if let Some(workspace) = workspace.upgrade() {
-                        this.handle_workspace_key_change(&workspace, cx);
+                        this.add_project_group_key(workspace.read(cx).project_group_key(cx));
+                    }
+                }
+                project::Event::WorktreeUpdatedRootRepoCommonDir(_) => {
+                    if let Some(workspace) = workspace.upgrade() {
+                        this.maybe_clear_provisional_project_group_key(&workspace, cx);
+                        this.add_project_group_key(
+                            this.project_group_key_for_workspace(&workspace, cx),
+                        );
+                        this.remove_stale_project_group_keys(cx);
+                        cx.notify();
                     }
                 }
                 _ => {}
@@ -583,111 +587,7 @@ impl MultiWorkspace {
         .detach();
     }
 
-    fn handle_workspace_key_change(
-        &mut self,
-        workspace: &Entity<Workspace>,
-        cx: &mut Context<Self>,
-    ) {
-        let workspace_id = workspace.entity_id();
-        let old_key = self.project_group_key_for_workspace(workspace, cx);
-        let new_key = workspace.read(cx).project_group_key(cx);
-
-        if new_key.path_list().paths().is_empty() || old_key == new_key {
-            return;
-        }
-
-        let active_workspace = self.workspace().clone();
-
-        self.set_workspace_group_key(workspace, new_key.clone());
-
-        let changed_root_paths = workspace.read(cx).root_paths(cx);
-        let old_paths = old_key.path_list().paths();
-        let new_paths = new_key.path_list().paths();
-
-        // Remove workspaces that already had the new key and have the same
-        // root paths (true duplicates that this workspace is replacing).
-        //
-        // NOTE: These are dropped without prompting for unsaved changes because
-        // the user explicitly added a folder that makes this workspace
-        // identical to the duplicate — they are intentionally overwriting it.
-        let duplicate_workspaces: Vec<Entity<Workspace>> = self
-            .workspaces
-            .iter()
-            .filter(|ws| {
-                ws.entity_id() != workspace_id
-                    && self.project_group_key_for_workspace(ws, cx) == new_key
-                    && ws.read(cx).root_paths(cx) == changed_root_paths
-            })
-            .cloned()
-            .collect();
-
-        if duplicate_workspaces.contains(&active_workspace) {
-            // The active workspace is among the duplicates — drop the
-            // incoming workspace instead so the user stays where they are.
-            self.detach_workspace(workspace, cx);
-            self.workspaces.retain(|w| w != workspace);
-        } else {
-            for ws in &duplicate_workspaces {
-                self.detach_workspace(ws, cx);
-                self.workspaces.retain(|w| w != ws);
-            }
-        }
-
-        // Propagate folder adds/removes to linked worktree siblings
-        // (different root paths, same old key) so they stay in the group.
-        let group_workspaces: Vec<Entity<Workspace>> = self
-            .workspaces
-            .iter()
-            .filter(|ws| {
-                ws.entity_id() != workspace_id
-                    && self.project_group_key_for_workspace(ws, cx) == old_key
-            })
-            .cloned()
-            .collect();
-
-        for workspace in &group_workspaces {
-            // Pre-set this to stop later WorktreeAdded events from triggering
-            self.set_workspace_group_key(&workspace, new_key.clone());
-
-            let project = workspace.read(cx).project().clone();
-
-            for added_path in new_paths.iter().filter(|p| !old_paths.contains(p)) {
-                project
-                    .update(cx, |project, cx| {
-                        project.find_or_create_worktree(added_path, true, cx)
-                    })
-                    .detach_and_log_err(cx);
-            }
-
-            for removed_path in old_paths.iter().filter(|p| !new_paths.contains(p)) {
-                project.update(cx, |project, cx| {
-                    project.remove_worktree_for_main_worktree_path(removed_path, cx);
-                });
-            }
-        }
-
-        // Restore the active workspace after removals may have shifted
-        // the index. If the previously active workspace was removed,
-        // fall back to the workspace whose key just changed.
-        if let ActiveWorkspace::Persistent(_) = &self.active_workspace {
-            let target = if self.workspaces.contains(&active_workspace) {
-                &active_workspace
-            } else {
-                workspace
-            };
-            if let Some(new_index) = self.workspaces.iter().position(|ws| ws == target) {
-                self.active_workspace = ActiveWorkspace::Persistent(new_index);
-            }
-        }
-
-        self.remove_stale_project_group_keys(cx);
-
-        cx.emit(MultiWorkspaceEvent::ProjectGroupKeyChanged { old_key, new_key });
-        self.serialize(cx);
-        cx.notify();
-    }
-
-    fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) {
+    pub fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) {
         if project_group_key.path_list().paths().is_empty() {
             return;
         }
@@ -698,12 +598,12 @@ impl MultiWorkspace {
         self.project_group_keys.insert(0, project_group_key);
     }
 
-    pub(crate) fn set_workspace_group_key(
+    pub fn set_provisional_project_group_key(
         &mut self,
         workspace: &Entity<Workspace>,
         project_group_key: ProjectGroupKey,
     ) {
-        self.workspace_group_keys
+        self.provisional_project_group_keys
             .insert(workspace.entity_id(), project_group_key.clone());
         self.add_project_group_key(project_group_key);
     }
@@ -713,12 +613,28 @@ impl MultiWorkspace {
         workspace: &Entity<Workspace>,
         cx: &App,
     ) -> ProjectGroupKey {
-        self.workspace_group_keys
+        self.provisional_project_group_keys
             .get(&workspace.entity_id())
             .cloned()
             .unwrap_or_else(|| workspace.read(cx).project_group_key(cx))
     }
 
+    fn maybe_clear_provisional_project_group_key(
+        &mut self,
+        workspace: &Entity<Workspace>,
+        cx: &App,
+    ) {
+        let live_key = workspace.read(cx).project_group_key(cx);
+        if self
+            .provisional_project_group_keys
+            .get(&workspace.entity_id())
+            .is_some_and(|key| *key == live_key)
+        {
+            self.provisional_project_group_keys
+                .remove(&workspace.entity_id());
+        }
+    }
+
     fn remove_stale_project_group_keys(&mut self, cx: &App) {
         let workspace_keys: HashSet<ProjectGroupKey> = self
             .workspaces
@@ -1129,6 +1045,7 @@ impl MultiWorkspace {
                     self.promote_transient(old, cx);
                 } else {
                     self.detach_workspace(&old, cx);
+                    cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id()));
                 }
             }
         } else {
@@ -1139,6 +1056,7 @@ impl MultiWorkspace {
             });
             if let Some(old) = self.active_workspace.set_transient(workspace) {
                 self.detach_workspace(&old, cx);
+                cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id()));
             }
         }
 
@@ -1165,7 +1083,7 @@ impl MultiWorkspace {
     /// Returns the index of the newly inserted workspace.
     fn promote_transient(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) -> usize {
         let project_group_key = self.project_group_key_for_workspace(&workspace, cx);
-        self.set_workspace_group_key(&workspace, project_group_key);
+        self.add_project_group_key(project_group_key);
         self.workspaces.push(workspace.clone());
         cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
         self.workspaces.len() - 1
@@ -1181,10 +1099,10 @@ impl MultiWorkspace {
         for workspace in std::mem::take(&mut self.workspaces) {
             if workspace != active {
                 self.detach_workspace(&workspace, cx);
+                cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id()));
             }
         }
         self.project_group_keys.clear();
-        self.workspace_group_keys.clear();
         self.active_workspace = ActiveWorkspace::Transient(active);
         cx.notify();
     }
@@ -1210,7 +1128,7 @@ impl MultiWorkspace {
                 workspace.set_multi_workspace(weak_self, cx);
             });
 
-            self.set_workspace_group_key(&workspace, project_group_key);
+            self.add_project_group_key(project_group_key);
             self.workspaces.push(workspace.clone());
             cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
             cx.notify();
@@ -1218,12 +1136,10 @@ impl MultiWorkspace {
         }
     }
 
-    /// Detaches a workspace: clears session state, DB binding, cached
-    /// group key, and emits `WorkspaceRemoved`. The DB row is preserved
-    /// so the workspace still appears in the recent-projects list.
+    /// Clears session state and DB binding for a workspace that is being
+    /// removed or replaced. The DB row is preserved so the workspace still
+    /// appears in the recent-projects list.
     fn detach_workspace(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
-        self.workspace_group_keys.remove(&workspace.entity_id());
-        cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id()));
         workspace.update(cx, |workspace, _cx| {
             workspace.session_id.take();
             workspace._schedule_serialize_workspace.take();
@@ -1397,46 +1313,6 @@ impl MultiWorkspace {
         tasks
     }
 
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn assert_project_group_key_integrity(&self, cx: &App) -> anyhow::Result<()> {
-        let stored_keys: HashSet<&ProjectGroupKey> = self.project_group_keys().collect();
-
-        let workspace_group_keys: HashSet<&ProjectGroupKey> =
-            self.workspace_group_keys.values().collect();
-        let extra_keys = &workspace_group_keys - &stored_keys;
-        anyhow::ensure!(
-            extra_keys.is_empty(),
-            "workspace_group_keys values not in project_group_keys: {:?}",
-            extra_keys,
-        );
-
-        let cached_ids: HashSet<EntityId> = self.workspace_group_keys.keys().copied().collect();
-        let workspace_ids: HashSet<EntityId> =
-            self.workspaces.iter().map(|ws| ws.entity_id()).collect();
-        anyhow::ensure!(
-            cached_ids == workspace_ids,
-            "workspace_group_keys entity IDs don't match workspaces.\n\
-             only in cache: {:?}\n\
-             only in workspaces: {:?}",
-            &cached_ids - &workspace_ids,
-            &workspace_ids - &cached_ids,
-        );
-
-        for workspace in self.workspaces() {
-            let live_key = workspace.read(cx).project_group_key(cx);
-            let cached_key = &self.workspace_group_keys[&workspace.entity_id()];
-            anyhow::ensure!(
-                *cached_key == live_key,
-                "workspace {:?} has live key {:?} but cached key {:?}",
-                workspace.entity_id(),
-                live_key,
-                cached_key,
-            );
-        }
-
-        Ok(())
-    }
-
     #[cfg(any(test, feature = "test-support"))]
     pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
         self.workspace().update(cx, |workspace, _cx| {
@@ -1595,6 +1471,7 @@ impl MultiWorkspace {
 
                 for workspace in &removed_workspaces {
                     this.detach_workspace(workspace, cx);
+                    cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id()));
                 }
 
                 let removed_any = !removed_workspaces.is_empty();

crates/workspace/src/multi_workspace_tests.rs 🔗

@@ -185,3 +185,157 @@ async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
         );
     });
 }
+
+#[gpui::test]
+async fn test_project_group_keys_on_worktree_added(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+    let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
+
+    let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+    multi_workspace.update(cx, |mw, cx| {
+        mw.open_sidebar(cx);
+    });
+
+    // Add a second worktree to the same project.
+    let (worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/root_b", true, cx)
+        })
+        .await
+        .unwrap();
+    worktree
+        .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_ne!(
+        initial_key, updated_key,
+        "key should change after adding a worktree"
+    );
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
+        assert_eq!(
+            keys.len(),
+            2,
+            "should have both the original and updated key"
+        );
+        assert_eq!(*keys[0], updated_key);
+        assert_eq!(*keys[1], initial_key);
+    });
+}
+
+#[gpui::test]
+async fn test_project_group_keys_on_worktree_removed(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+    let project = Project::test(fs, ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
+
+    let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+    multi_workspace.update(cx, |mw, cx| {
+        mw.open_sidebar(cx);
+    });
+
+    // Remove one worktree.
+    let worktree_b_id = project.read_with(cx, |project, cx| {
+        project
+            .worktrees(cx)
+            .find(|wt| wt.read(cx).root_name().as_unix_str() == "root_b")
+            .unwrap()
+            .read(cx)
+            .id()
+    });
+    project.update(cx, |project, cx| {
+        project.remove_worktree(worktree_b_id, cx);
+    });
+    cx.run_until_parked();
+
+    let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_ne!(
+        initial_key, updated_key,
+        "key should change after removing a worktree"
+    );
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
+        assert_eq!(
+            keys.len(),
+            2,
+            "should accumulate both the original and post-removal key"
+        );
+        assert_eq!(*keys[0], updated_key);
+        assert_eq!(*keys[1], initial_key);
+    });
+}
+
+#[gpui::test]
+async fn test_project_group_keys_across_multiple_workspaces_and_worktree_changes(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_c", json!({ "file.txt": "" })).await;
+    let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+    let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
+
+    let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+    let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+    multi_workspace.update(cx, |mw, cx| {
+        mw.open_sidebar(cx);
+    });
+
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_b, window, cx);
+    });
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(mw.project_group_keys().count(), 2);
+    });
+
+    // Now add a worktree to project_a. This should produce a third key.
+    let (worktree, _) = project_a
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/root_c", true, cx)
+        })
+        .await
+        .unwrap();
+    worktree
+        .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    let key_a_updated = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_ne!(key_a, key_a_updated);
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
+        assert_eq!(
+            keys.len(),
+            3,
+            "should have key_a, key_b, and the updated key_a with root_c"
+        );
+        assert_eq!(*keys[0], key_a_updated);
+        assert_eq!(*keys[1], key_b);
+        assert_eq!(*keys[2], key_a);
+    });
+}

crates/workspace/src/workspace.rs 🔗

@@ -9886,7 +9886,7 @@ async fn open_remote_project_inner(
         });
 
         if let Some(project_group_key) = provisional_project_group_key.clone() {
-            multi_workspace.set_workspace_group_key(&new_workspace, project_group_key);
+            multi_workspace.set_provisional_project_group_key(&new_workspace, project_group_key);
         }
         multi_workspace.activate(new_workspace.clone(), window, cx);
         new_workspace