Handle changing root paths without splitting in the sidebar (#53566)

Mikayla Maki and Eric Holk created

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #ISSUE

Release Notes:

- N/A

---------

Co-authored-by: Eric Holk <eric@zed.dev>

Change summary

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

Detailed changes

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -477,6 +477,36 @@ 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,10 +283,8 @@ impl ListEntry {
                 }
             }
             ListEntry::ProjectHeader { key, .. } => multi_workspace
-                .workspaces()
-                .find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list())
+                .workspaces_for_project_group(key, cx)
                 .cloned()
-                .into_iter()
                 .collect(),
             ListEntry::ViewMore { .. } => Vec::new(),
         }
@@ -365,35 +363,81 @@ 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.
+/// 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.
 fn worktree_info_from_thread_paths(
     folder_paths: &PathList,
-    group_key: &project::ProjectGroupKey,
-) -> impl Iterator<Item = WorktreeInfo> {
-    let main_paths = group_key.path_list().paths();
-    folder_paths.paths().iter().filter_map(|path| {
+    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() {
         let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path());
         if is_main {
-            let name = path.file_name()?.to_string_lossy().to_string();
-            Some(WorktreeInfo {
-                name: SharedString::from(name),
+            let Some(name) = path.file_name() else {
+                continue;
+            };
+            infos.push(WorktreeInfo {
+                name: SharedString::from(name.to_string_lossy().to_string()),
                 full_path: SharedString::from(path.display().to_string()),
                 highlight_positions: Vec::new(),
                 kind: ui::WorktreeKind::Main,
-            })
+            });
         } else {
-            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(),
+            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,
                 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
@@ -480,6 +524,16 @@ 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();
@@ -912,6 +966,21 @@ 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()
@@ -949,26 +1018,28 @@ 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: 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(),
-                    }
-                };
+                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(),
+                        }
+                    };
 
                 // Main code path: one query per group via main_worktree_paths.
                 // The main_worktree_paths column is set on all new threads and
@@ -1184,11 +1255,17 @@ 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 worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key);
+                        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,
+                        );
                         entries.push(ListEntry::DraftThread {
                             key: group_key.clone(),
                             workspace: None,
-                            worktrees: worktrees.collect(),
+                            worktrees,
                         });
                     }
                 }
@@ -1212,9 +1289,14 @@ impl Sidebar {
                             continue;
                         }
                         let ws_path_list = workspace_path_list(ws, cx);
-                        let has_linked_worktrees =
-                            worktree_info_from_thread_paths(&ws_path_list, &group_key)
-                                .any(|wt| wt.kind == ui::WorktreeKind::Linked);
+                        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);
                         if !has_linked_worktrees {
                             continue;
                         }
@@ -1227,8 +1309,11 @@ impl Sidebar {
                         if has_threads {
                             continue;
                         }
-                        let worktrees: Vec<WorktreeInfo> =
-                            worktree_info_from_thread_paths(&ws_path_list, &group_key).collect();
+                        let worktrees = worktree_info_from_thread_paths(
+                            &ws_path_list,
+                            &ws_main_paths,
+                            &linked_to_main,
+                        );
 
                         entries.push(ListEntry::DraftThread {
                             key: group_key.clone(),

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -251,6 +251,32 @@ 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);
@@ -322,6 +348,11 @@ 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,
@@ -338,7 +369,7 @@ fn visible_entries_as_strings(
                     }
                     ListEntry::Thread(thread) => {
                         let title = thread.metadata.title.as_ref();
-                        let active = if thread.is_live { " *" } else { "" };
+                        let live = if thread.is_live { " *" } else { "" };
                         let status_str = match thread.status {
                             AgentThreadStatus::Running => " (running)",
                             AgentThreadStatus::Error => " (error)",
@@ -354,7 +385,7 @@ fn visible_entries_as_strings(
                             ""
                         };
                         let worktree = format_linked_worktree_chips(&thread.worktrees);
-                        format!("  {title}{worktree}{active}{status_str}{notified}{selected}")
+                        format!("  {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}")
                     }
                     ListEntry::ViewMore {
                         is_fully_expanded, ..
@@ -374,7 +405,7 @@ fn visible_entries_as_strings(
                         if workspace.is_some() {
                             format!("  [+ New Thread{}]{}", worktree, selected)
                         } else {
-                            format!("  [~ Draft{}]{}", worktree, selected)
+                            format!("  [~ Draft{}]{}{}", worktree, active_indicator, selected)
                         }
                     }
                 }
@@ -543,7 +574,10 @@ 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]",
+        ]
     );
 }
 
@@ -579,6 +613,7 @@ 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",
@@ -609,7 +644,11 @@ 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
@@ -620,7 +659,11 @@ 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",
+        ]
     );
 }
 
@@ -639,6 +682,7 @@ 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",
@@ -749,7 +793,11 @@ 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
@@ -760,7 +808,10 @@ 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
@@ -771,7 +822,11 @@ 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",
+        ]
     );
 }
 
@@ -941,6 +996,7 @@ 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",
@@ -1104,10 +1160,14 @@ 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 (index 0)
+    // Focus the sidebar and select the header
     focus_sidebar(&sidebar, cx);
     sidebar.update_in(cx, |sidebar, _window, _cx| {
         sidebar.selection = Some(0);
@@ -1119,7 +1179,10 @@ 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
@@ -1128,7 +1191,11 @@ 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",
+        ]
     );
 }
 
@@ -1179,7 +1246,11 @@ 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.
@@ -1193,7 +1264,10 @@ 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
@@ -1202,7 +1276,11 @@ 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
@@ -1229,7 +1307,11 @@ 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
@@ -1239,7 +1321,10 @@ 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",
+        ]
     );
 }
 
@@ -1253,7 +1338,10 @@ 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
@@ -1385,7 +1473,12 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
     entries[1..].sort();
     assert_eq!(
         entries,
-        vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
+        vec![
+            //
+            "v [my-project]",
+            "  Hello * (active)",
+            "  Hello * (running)",
+        ]
     );
 }
 
@@ -1478,7 +1571,11 @@ 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)",]
+        vec![
+            //
+            "v [project-a]",
+            "  Hello * (running) (active)",
+        ]
     );
 
     // Complete thread A's turn (transition Running → Completed).
@@ -1488,7 +1585,11 @@ 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 * (!)",]
+        vec![
+            //
+            "v [project-a]",
+            "  Hello * (!) (active)",
+        ]
     );
 }
 
@@ -1528,6 +1629,7 @@ 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",
@@ -1540,7 +1642,11 @@ 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.
@@ -1575,6 +1681,7 @@ 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",
         ]
@@ -1585,6 +1692,7 @@ 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",
         ]
@@ -1615,7 +1723,12 @@ 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.
@@ -1623,7 +1736,11 @@ 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.
@@ -1633,6 +1750,7 @@ 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",
@@ -1689,6 +1807,7 @@ 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",
@@ -1699,7 +1818,11 @@ 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.
@@ -1715,6 +1838,7 @@ 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",
@@ -1774,6 +1898,7 @@ 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",
@@ -1785,7 +1910,11 @@ 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
@@ -1795,7 +1924,11 @@ 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.
@@ -1804,6 +1937,7 @@ 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",
@@ -1817,6 +1951,7 @@ 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",
@@ -1866,7 +2001,11 @@ 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")),
@@ -1902,14 +2041,21 @@ 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",
+        ]
     );
 }
 
@@ -1943,6 +2089,7 @@ 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",
@@ -1955,6 +2102,7 @@ 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",
@@ -1966,6 +2114,7 @@ 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",
@@ -2006,7 +2155,11 @@ 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.
@@ -2067,7 +2220,12 @@ 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.
@@ -2119,7 +2277,11 @@ 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 *"]
+        vec![
+            //
+            "v [my-project]",
+            "  Hello * (active)",
+        ]
     );
 
     // Simulate the agent generating a title. The notification chain is:
@@ -2141,7 +2303,11 @@ 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 *"]
+        vec![
+            //
+            "v [my-project]",
+            "  Friendly Greeting with AI * (active)",
+        ]
     );
 }
 
@@ -2292,177 +2458,816 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
         );
     });
 
-    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();
+    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;
 
-    // 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",
-        );
-    });
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 
-    workspace_b.update_in(cx, |workspace, window, cx| {
-        workspace.focus_handle(cx).focus(window, cx);
-    });
-    cx.run_until_parked();
+    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;
 
-    sidebar.read_with(cx, |sidebar, _cx| {
-        assert_active_thread(
-            sidebar,
-            &session_id_a,
-            "Defocusing the sidebar should not change focused_thread",
-        );
-    });
+    main_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+    worktree_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
 
-    // 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);
-        }
+    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);
+
+    // 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);
+
+    // 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)
     });
+    add_agent_panel(&wt_workspace, 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"
+    // 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"
         );
+        assert_eq!(mw.workspaces().count(), 2, "should have 2 workspaces");
     });
 
-    // ── 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();
+    // 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;
 
-    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"
+    // 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"
         );
     });
-}
-
-#[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;
+    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
     cx.run_until_parked();
 
-    // Verify the thread appears in the sidebar.
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [project-a]", "  Hello *",]
+        vec![
+            //
+            "v [project]",
+            "  [~ Draft {wt-feature}] (active)",
+            "  Worktree Thread {wt-feature}",
+            "  Main Thread",
+        ]
     );
 
-    // 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
+    // Add /other-project as a folder to the main workspace.
+    main_project
         .update(cx, |project, cx| {
-            project.find_or_create_worktree("/project-b", true, cx)
+            project.find_or_create_worktree("/other-project", 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, 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.
+    // 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());
     assert_eq!(
-        visible_entries_as_strings(&sidebar, cx),
-        vec![
-            "v [project-a, project-b]", //
-            "  Hello *",
-            "v [project-a]",
-        ]
+        wt_worktree_count, 2,
+        "linked worktree project should have gotten the new folder"
     );
 
-    // 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,
+    // 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"
         );
     });
 
-    // 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);
+    // 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"
+        );
     });
+
+    // Both threads should still be visible in the sidebar.
+    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(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",
-        );
-    });
+    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",
+        ]
+    );
 }
 
 #[gpui::test]

crates/workspace/src/multi_workspace.rs 🔗

@@ -101,6 +101,10 @@ pub enum MultiWorkspaceEvent {
     ActiveWorkspaceChanged,
     WorkspaceAdded(Entity<Workspace>),
     WorkspaceRemoved(EntityId),
+    ProjectGroupKeyChanged {
+        old_key: ProjectGroupKey,
+        new_key: ProjectGroupKey,
+    },
 }
 
 pub enum SidebarEvent {
@@ -302,7 +306,7 @@ pub struct MultiWorkspace {
     workspaces: Vec<Entity<Workspace>>,
     active_workspace: ActiveWorkspace,
     project_group_keys: Vec<ProjectGroupKey>,
-    provisional_project_group_keys: HashMap<EntityId, ProjectGroupKey>,
+    workspace_group_keys: HashMap<EntityId, ProjectGroupKey>,
     sidebar: Option<Box<dyn SidebarHandle>>,
     sidebar_open: bool,
     sidebar_overlay: Option<AnyView>,
@@ -355,7 +359,7 @@ impl MultiWorkspace {
         Self {
             window_id: window.window_handle().window_id(),
             project_group_keys: Vec::new(),
-            provisional_project_group_keys: HashMap::default(),
+            workspace_group_keys: HashMap::default(),
             workspaces: Vec::new(),
             active_workspace: ActiveWorkspace::Transient(workspace),
             sidebar: None,
@@ -559,19 +563,11 @@ 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::WorktreeAdded(_)
+                | project::Event::WorktreeRemoved(_)
+                | project::Event::WorktreeUpdatedRootRepoCommonDir(_) => {
                     if let Some(workspace) = workspace.upgrade() {
-                        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();
+                        this.handle_workspace_key_change(&workspace, cx);
                     }
                 }
                 _ => {}
@@ -587,7 +583,111 @@ impl MultiWorkspace {
         .detach();
     }
 
-    pub fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) {
+    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) {
         if project_group_key.path_list().paths().is_empty() {
             return;
         }
@@ -598,12 +698,12 @@ impl MultiWorkspace {
         self.project_group_keys.insert(0, project_group_key);
     }
 
-    pub fn set_provisional_project_group_key(
+    pub(crate) fn set_workspace_group_key(
         &mut self,
         workspace: &Entity<Workspace>,
         project_group_key: ProjectGroupKey,
     ) {
-        self.provisional_project_group_keys
+        self.workspace_group_keys
             .insert(workspace.entity_id(), project_group_key.clone());
         self.add_project_group_key(project_group_key);
     }
@@ -613,28 +713,12 @@ impl MultiWorkspace {
         workspace: &Entity<Workspace>,
         cx: &App,
     ) -> ProjectGroupKey {
-        self.provisional_project_group_keys
+        self.workspace_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
@@ -1045,7 +1129,6 @@ impl MultiWorkspace {
                     self.promote_transient(old, cx);
                 } else {
                     self.detach_workspace(&old, cx);
-                    cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id()));
                 }
             }
         } else {
@@ -1056,7 +1139,6 @@ impl MultiWorkspace {
             });
             if let Some(old) = self.active_workspace.set_transient(workspace) {
                 self.detach_workspace(&old, cx);
-                cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id()));
             }
         }
 
@@ -1083,7 +1165,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.add_project_group_key(project_group_key);
+        self.set_workspace_group_key(&workspace, project_group_key);
         self.workspaces.push(workspace.clone());
         cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
         self.workspaces.len() - 1
@@ -1099,10 +1181,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();
     }
@@ -1128,7 +1210,7 @@ impl MultiWorkspace {
                 workspace.set_multi_workspace(weak_self, cx);
             });
 
-            self.add_project_group_key(project_group_key);
+            self.set_workspace_group_key(&workspace, project_group_key);
             self.workspaces.push(workspace.clone());
             cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
             cx.notify();
@@ -1136,10 +1218,12 @@ impl MultiWorkspace {
         }
     }
 
-    /// 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.
+    /// 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.
     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();
@@ -1313,6 +1397,46 @@ 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| {
@@ -1471,7 +1595,6 @@ 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,157 +185,3 @@ 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_provisional_project_group_key(&new_workspace, project_group_key);
+            multi_workspace.set_workspace_group_key(&new_workspace, project_group_key);
         }
         multi_workspace.activate(new_workspace.clone(), window, cx);
         new_workspace