Rework sidebar rendering to use MultiWorkspace's project groups (#53096)

Max Brunsfeld , Eric Holk , and Mikayla Maki created

Release Notes:

* [x] It's possible to get into a state where agent panel shows a thread
that is archived

- N/A

---------

Co-authored-by: Eric Holk <eric@zed.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

Cargo.lock                                  |   1 
crates/agent_ui/src/agent_panel.rs          |   4 
crates/project/src/project.rs               |   6 
crates/sidebar/Cargo.toml                   |   1 
crates/sidebar/src/project_group_builder.rs | 282 ---------
crates/sidebar/src/sidebar.rs               | 664 +++++++++++-----------
crates/sidebar/src/sidebar_tests.rs         | 347 +++++------
crates/workspace/src/multi_workspace.rs     |  20 
8 files changed, 525 insertions(+), 800 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -15871,7 +15871,6 @@ dependencies = [
  "agent_ui",
  "anyhow",
  "chrono",
- "collections",
  "editor",
  "feature_flags",
  "fs",

crates/agent_ui/src/agent_panel.rs 🔗

@@ -2076,6 +2076,10 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if let Some(store) = ThreadMetadataStore::try_global(cx) {
+            store.update(cx, |store, cx| store.unarchive(&session_id, cx));
+        }
+
         if let Some(conversation_view) = self.background_threads.remove(&session_id) {
             self.set_active_view(
                 ActiveView::AgentThread { conversation_view },

crates/project/src/project.rs 🔗

@@ -6049,11 +6049,7 @@ impl ProjectGroupKey {
     /// Creates a new `ProjectGroupKey` with the given path list.
     ///
     /// The path list should point to the git main worktree paths for a project.
-    ///
-    /// This should be used only in a few places to make sure we can ensure the
-    /// main worktree path invariant. Namely, this should only be called from
-    /// [`Workspace`].
-    pub(crate) fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
+    pub fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
         Self { paths, host }
     }
 

crates/sidebar/Cargo.toml 🔗

@@ -23,7 +23,6 @@ agent_settings.workspace = true
 agent_ui = { workspace = true, features = ["audio"] }
 anyhow.workspace = true
 chrono.workspace = true
-collections.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true

crates/sidebar/src/project_group_builder.rs 🔗

@@ -1,282 +0,0 @@
-//! The sidebar groups threads by a canonical path list.
-//!
-//! Threads have a path list associated with them, but this is the absolute path
-//! of whatever worktrees they were associated with. In the sidebar, we want to
-//! group all threads by their main worktree, and then we add a worktree chip to
-//! the sidebar entry when that thread is in another worktree.
-//!
-//! This module is provides the functions and structures necessary to do this
-//! lookup and mapping.
-
-use collections::{HashMap, HashSet, vecmap::VecMap};
-use gpui::{App, Entity};
-use project::ProjectGroupKey;
-use std::{
-    path::{Path, PathBuf},
-    sync::Arc,
-};
-use workspace::{MultiWorkspace, PathList, Workspace};
-
-#[derive(Default)]
-pub struct ProjectGroup {
-    pub workspaces: Vec<Entity<Workspace>>,
-    /// Root paths of all open workspaces in this group. Used to skip
-    /// redundant thread-store queries for linked worktrees that already
-    /// have an open workspace.
-    covered_paths: HashSet<Arc<Path>>,
-}
-
-impl ProjectGroup {
-    fn add_workspace(&mut self, workspace: &Entity<Workspace>, cx: &App) {
-        if !self.workspaces.contains(workspace) {
-            self.workspaces.push(workspace.clone());
-        }
-        for path in workspace.read(cx).root_paths(cx) {
-            self.covered_paths.insert(path);
-        }
-    }
-
-    pub fn first_workspace(&self) -> &Entity<Workspace> {
-        self.workspaces
-            .first()
-            .expect("groups always have at least one workspace")
-    }
-
-    pub fn main_workspace(&self, cx: &App) -> &Entity<Workspace> {
-        self.workspaces
-            .iter()
-            .find(|ws| {
-                !crate::root_repository_snapshots(ws, cx)
-                    .any(|snapshot| snapshot.is_linked_worktree())
-            })
-            .unwrap_or_else(|| self.first_workspace())
-    }
-}
-
-pub struct ProjectGroupBuilder {
-    /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
-    directory_mappings: HashMap<PathBuf, PathBuf>,
-    project_groups: VecMap<ProjectGroupKey, ProjectGroup>,
-}
-
-impl ProjectGroupBuilder {
-    fn new() -> Self {
-        Self {
-            directory_mappings: HashMap::default(),
-            project_groups: VecMap::new(),
-        }
-    }
-
-    pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self {
-        let mut builder = Self::new();
-        // First pass: collect all directory mappings from every workspace
-        // so we know how to canonicalize any path (including linked
-        // worktree paths discovered by the main repo's workspace).
-        for workspace in mw.workspaces() {
-            builder.add_workspace_mappings(workspace.read(cx), cx);
-        }
-
-        // Second pass: group each workspace using canonical paths derived
-        // from the full set of mappings.
-        for workspace in mw.workspaces() {
-            let group_name = workspace.read(cx).project_group_key(cx);
-            builder
-                .project_group_entry(&group_name)
-                .add_workspace(workspace, cx);
-        }
-        builder
-    }
-
-    fn project_group_entry(&mut self, name: &ProjectGroupKey) -> &mut ProjectGroup {
-        self.project_groups.entry_ref(name).or_insert_default()
-    }
-
-    fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
-        let old = self
-            .directory_mappings
-            .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
-        if let Some(old) = old {
-            debug_assert_eq!(
-                &old, original_repo,
-                "all worktrees should map to the same main worktree"
-            );
-        }
-    }
-
-    pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
-        for repo in workspace.project().read(cx).repositories(cx).values() {
-            let snapshot = repo.read(cx).snapshot();
-
-            self.add_mapping(
-                &snapshot.work_directory_abs_path,
-                &snapshot.original_repo_abs_path,
-            );
-
-            for worktree in snapshot.linked_worktrees.iter() {
-                self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
-            }
-        }
-    }
-
-    pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
-        self.directory_mappings
-            .get(path)
-            .map(AsRef::as_ref)
-            .unwrap_or(path)
-    }
-
-    /// Whether the given group should load threads for a linked worktree
-    /// at `worktree_path`. Returns `false` if the worktree already has an
-    /// open workspace in the group (its threads are loaded via the
-    /// workspace loop) or if the worktree's canonical path list doesn't
-    /// match `group_path_list`.
-    pub fn group_owns_worktree(
-        &self,
-        group: &ProjectGroup,
-        group_path_list: &PathList,
-        worktree_path: &Path,
-    ) -> bool {
-        if group.covered_paths.contains(worktree_path) {
-            return false;
-        }
-        let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
-        canonical == *group_path_list
-    }
-
-    /// Canonicalizes every path in a [`PathList`] using the builder's
-    /// directory mappings.
-    fn canonicalize_path_list(&self, path_list: &PathList) -> PathList {
-        let paths: Vec<_> = path_list
-            .paths()
-            .iter()
-            .map(|p| self.canonicalize_path(p).to_path_buf())
-            .collect();
-        PathList::new(&paths)
-    }
-
-    pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupKey, &ProjectGroup)> {
-        self.project_groups.iter()
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::sync::Arc;
-
-    use super::*;
-    use fs::FakeFs;
-    use gpui::TestAppContext;
-    use settings::SettingsStore;
-
-    fn init_test(cx: &mut TestAppContext) {
-        cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-            theme_settings::init(theme::LoadThemes::JustBase, cx);
-        });
-    }
-
-    async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/project",
-            serde_json::json!({
-                ".git": {
-                    "worktrees": {
-                        "feature-a": {
-                            "commondir": "../../",
-                            "HEAD": "ref: refs/heads/feature-a",
-                        },
-                    },
-                },
-                "src": {},
-            }),
-        )
-        .await;
-        fs.insert_tree(
-            "/wt/feature-a",
-            serde_json::json!({
-                ".git": "gitdir: /project/.git/worktrees/feature-a",
-                "src": {},
-            }),
-        )
-        .await;
-        fs.add_linked_worktree_for_repo(
-            std::path::Path::new("/project/.git"),
-            false,
-            git::repository::Worktree {
-                path: std::path::PathBuf::from("/wt/feature-a"),
-                ref_name: Some("refs/heads/feature-a".into()),
-                sha: "abc".into(),
-                is_main: false,
-            },
-        )
-        .await;
-        fs
-    }
-
-    #[gpui::test]
-    async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
-        init_test(cx);
-        let fs = create_fs_with_main_and_worktree(cx).await;
-        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
-        let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
-        project
-            .update(cx, |project, cx| project.git_scans_complete(cx))
-            .await;
-
-        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
-            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
-        });
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            let mut canonicalizer = ProjectGroupBuilder::new();
-            for workspace in mw.workspaces() {
-                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
-            }
-
-            // The main repo path should canonicalize to itself.
-            assert_eq!(
-                canonicalizer.canonicalize_path(Path::new("/project")),
-                Path::new("/project"),
-            );
-
-            // An unknown path returns None.
-            assert_eq!(
-                canonicalizer.canonicalize_path(Path::new("/something/else")),
-                Path::new("/something/else"),
-            );
-        });
-    }
-
-    #[gpui::test]
-    async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
-        init_test(cx);
-        let fs = create_fs_with_main_and_worktree(cx).await;
-        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
-        // Open the worktree checkout as its own project.
-        let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
-        project
-            .update(cx, |project, cx| project.git_scans_complete(cx))
-            .await;
-
-        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
-            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
-        });
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            let mut canonicalizer = ProjectGroupBuilder::new();
-            for workspace in mw.workspaces() {
-                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
-            }
-
-            // The worktree checkout path should canonicalize to the main repo.
-            assert_eq!(
-                canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
-                Path::new("/project"),
-            );
-        });
-    }
-}

crates/sidebar/src/sidebar.rs 🔗

@@ -23,7 +23,9 @@ use gpui::{
 use menu::{
     Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
 };
-use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name};
+use project::{
+    AgentId, AgentRegistryStore, Event as ProjectEvent, ProjectGroupKey, linked_worktree_short_name,
+};
 use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
 use remote::RemoteConnectionOptions;
 use ui::utils::platform_title_bar_height;
@@ -54,10 +56,6 @@ use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
 
 use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
 
-use crate::project_group_builder::ProjectGroupBuilder;
-
-mod project_group_builder;
-
 #[cfg(test)]
 mod sidebar_tests;
 
@@ -136,13 +134,7 @@ impl ActiveEntry {
             (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
                 thread.metadata.session_id == *session_id
             }
-            (
-                ActiveEntry::Draft(workspace),
-                ListEntry::NewThread {
-                    workspace: entry_workspace,
-                    ..
-                },
-            ) => workspace == entry_workspace,
+            (ActiveEntry::Draft(_workspace), ListEntry::DraftThread { .. }) => true,
             _ => false,
         }
     }
@@ -209,9 +201,8 @@ impl ThreadEntry {
 #[derive(Clone)]
 enum ListEntry {
     ProjectHeader {
-        path_list: PathList,
+        key: ProjectGroupKey,
         label: SharedString,
-        workspace: Entity<Workspace>,
         highlight_positions: Vec<usize>,
         has_running_threads: bool,
         waiting_thread_count: usize,
@@ -219,30 +210,25 @@ enum ListEntry {
     },
     Thread(ThreadEntry),
     ViewMore {
-        path_list: PathList,
+        key: ProjectGroupKey,
         is_fully_expanded: bool,
     },
+    /// The user's active draft thread. Shows a prefix of the currently-typed
+    /// prompt, or "Untitled Thread" if the prompt is empty.
+    DraftThread {
+        worktrees: Vec<WorktreeInfo>,
+    },
+    /// A convenience row for starting a new thread. Shown when a project group
+    /// has no threads, or when the active workspace contains linked worktrees
+    /// with no threads for that specific worktree set.
     NewThread {
-        path_list: PathList,
-        workspace: Entity<Workspace>,
+        key: project::ProjectGroupKey,
         worktrees: Vec<WorktreeInfo>,
     },
 }
 
 #[cfg(test)]
 impl ListEntry {
-    fn workspace(&self) -> Option<Entity<Workspace>> {
-        match self {
-            ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
-            ListEntry::Thread(thread_entry) => match &thread_entry.workspace {
-                ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
-                ThreadEntryWorkspace::Closed(_) => None,
-            },
-            ListEntry::ViewMore { .. } => None,
-            ListEntry::NewThread { workspace, .. } => Some(workspace.clone()),
-        }
-    }
-
     fn session_id(&self) -> Option<&acp::SessionId> {
         match self {
             ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id),
@@ -321,27 +307,32 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
 
 /// Derives worktree display info from a thread's stored path list.
 ///
-/// For each path in the thread's `folder_paths` that canonicalizes to a
-/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`]
-/// with the short worktree name and full path.
+/// For each path in the thread's `folder_paths` that is not one of the
+/// group's main paths (i.e. it's a git linked worktree), produces a
+/// [`WorktreeInfo`] with the short worktree name and full path.
 fn worktree_info_from_thread_paths(
     folder_paths: &PathList,
-    project_groups: &ProjectGroupBuilder,
+    group_key: &project::ProjectGroupKey,
 ) -> Vec<WorktreeInfo> {
+    let main_paths = group_key.path_list().paths();
     folder_paths
         .paths()
         .iter()
         .filter_map(|path| {
-            let canonical = project_groups.canonicalize_path(path);
-            if canonical != path.as_path() {
-                Some(WorktreeInfo {
-                    name: linked_worktree_short_name(canonical, path).unwrap_or_default(),
-                    full_path: SharedString::from(path.display().to_string()),
-                    highlight_positions: Vec::new(),
-                })
-            } else {
-                None
+            if main_paths.iter().any(|mp| mp.as_path() == path.as_path()) {
+                return None;
             }
+            // Find the main path whose file name matches this linked
+            // worktree's file name, falling back to the first main path.
+            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(),
+            })
         })
         .collect()
 }
@@ -677,10 +668,41 @@ impl Sidebar {
         result
     }
 
+    /// Finds an open workspace whose project group key matches the given path list.
+    fn workspace_for_group(&self, path_list: &PathList, cx: &App) -> Option<Entity<Workspace>> {
+        let mw = self.multi_workspace.upgrade()?;
+        let mw = mw.read(cx);
+        mw.workspaces()
+            .iter()
+            .find(|ws| ws.read(cx).project_group_key(cx).path_list() == path_list)
+            .cloned()
+    }
+
+    /// Opens a new workspace for a group that has no open workspaces.
+    fn open_workspace_for_group(
+        &mut self,
+        path_list: &PathList,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        let paths: Vec<std::path::PathBuf> =
+            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
+
+        multi_workspace
+            .update(cx, |mw, cx| {
+                mw.open_project(paths, workspace::OpenMode::Activate, window, cx)
+            })
+            .detach_and_log_err(cx);
+    }
+
     /// Rebuilds the sidebar contents from current workspace and thread state.
     ///
-    /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
-    /// repository, then populates thread entries from the metadata store and
+    /// Iterates [`MultiWorkspace::project_group_keys`] to determine project
+    /// groups, then populates thread entries from the metadata store and
     /// merges live thread info from active agent panels.
     ///
     /// Aim for a single forward pass over workspaces and threads plus an
@@ -764,11 +786,6 @@ impl Sidebar {
         let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
         let mut project_header_indices: Vec<usize> = Vec::new();
 
-        // Use ProjectGroupBuilder to canonically group workspaces by their
-        // main git repository. This replaces the manual absorbed-workspace
-        // detection that was here before.
-        let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
-
         let has_open_projects = workspaces
             .iter()
             .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
@@ -785,38 +802,28 @@ impl Sidebar {
             (icon, icon_from_external_svg)
         };
 
-        for (group_name, group) in project_groups.groups() {
-            let path_list = group_name.path_list().clone();
+        for (group_key, group_workspaces) in mw.project_groups(cx) {
+            let path_list = group_key.path_list().clone();
             if path_list.paths().is_empty() {
                 continue;
             }
 
-            let label = group_name.display_name();
+            let label = group_key.display_name();
 
             let is_collapsed = self.collapsed_groups.contains(&path_list);
             let should_load_threads = !is_collapsed || !query.is_empty();
 
             let is_active = active_workspace
                 .as_ref()
-                .is_some_and(|active| group.workspaces.contains(active));
-
-            // Pick a representative workspace for the group: prefer the active
-            // workspace if it belongs to this group, otherwise use the main
-            // repo workspace (not a linked worktree).
-            let representative_workspace = active_workspace
-                .as_ref()
-                .filter(|_| is_active)
-                .unwrap_or_else(|| group.main_workspace(cx));
+                .is_some_and(|active| group_workspaces.contains(active));
 
             // Collect live thread infos from all workspaces in this group.
-            let live_infos: Vec<_> = group
-                .workspaces
+            let live_infos: Vec<_> = group_workspaces
                 .iter()
                 .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
                 .collect();
 
             let mut threads: Vec<ThreadEntry> = Vec::new();
-            let mut threadless_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeInfo>)> = Vec::new();
             let mut has_running_threads = false;
             let mut waiting_thread_count: usize = 0;
 
@@ -824,61 +831,88 @@ impl Sidebar {
                 let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
                 let thread_store = ThreadMetadataStore::global(cx);
 
-                // Load threads from each workspace in the group.
-                for workspace in &group.workspaces {
-                    let ws_path_list = workspace_path_list(workspace, cx);
-                    let mut workspace_rows = thread_store
-                        .read(cx)
-                        .entries_for_path(&ws_path_list)
-                        .cloned()
-                        .peekable();
-                    if workspace_rows.peek().is_none() {
-                        let worktrees =
-                            worktree_info_from_thread_paths(&ws_path_list, &project_groups);
-                        threadless_workspaces.push((workspace.clone(), worktrees));
+                // Build a lookup from workspace root paths to their workspace
+                // entity, used to assign ThreadEntryWorkspace::Open for threads
+                // whose folder_paths match an open workspace.
+                let workspace_by_path_list: HashMap<PathList, &Entity<Workspace>> =
+                    group_workspaces
+                        .iter()
+                        .map(|ws| (workspace_path_list(ws, cx), ws))
+                        .collect();
+
+                // Resolve a ThreadEntryWorkspace for a thread row. If any open
+                // workspace's root paths match the thread's folder_paths, use
+                // Open; otherwise use Closed.
+                let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace {
+                    workspace_by_path_list
+                        .get(&row.folder_paths)
+                        .map(|ws| ThreadEntryWorkspace::Open((*ws).clone()))
+                        .unwrap_or_else(|| ThreadEntryWorkspace::Closed(row.folder_paths.clone()))
+                };
+
+                // 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, &group_key);
+                    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(),
                     }
-                    for row in workspace_rows {
-                        if !seen_session_ids.insert(row.session_id.clone()) {
-                            continue;
-                        }
-                        let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
-                        let worktrees =
-                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
-                        threads.push(ThreadEntry {
-                            metadata: row,
-                            icon,
-                            icon_from_external_svg,
-                            status: AgentThreadStatus::default(),
-                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
-                            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
+                // points to the group's canonical paths regardless of which
+                // linked worktree the thread was opened in.
+                for row in thread_store
+                    .read(cx)
+                    .entries_for_main_worktree_path(&path_list)
+                    .cloned()
+                {
+                    if !seen_session_ids.insert(row.session_id.clone()) {
+                        continue;
                     }
+                    let workspace = resolve_workspace(&row);
+                    threads.push(make_thread_entry(row, workspace));
                 }
 
-                // Load threads from linked git worktrees whose
-                // canonical paths belong to this group.
-                let linked_worktree_queries = group
-                    .workspaces
-                    .iter()
-                    .flat_map(|ws| root_repository_snapshots(ws, cx))
-                    .filter(|snapshot| !snapshot.is_linked_worktree())
-                    .flat_map(|snapshot| {
-                        snapshot
-                            .linked_worktrees()
-                            .iter()
-                            .filter(|wt| {
-                                project_groups.group_owns_worktree(group, &path_list, &wt.path)
-                            })
-                            .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
-                            .collect::<Vec<_>>()
-                    });
+                // Legacy threads did not have `main_worktree_paths` populated, so they
+                // must be queried by their `folder_paths`.
+
+                // Load any legacy threads for the main worktrees of this project group.
+                for row in thread_store.read(cx).entries_for_path(&path_list).cloned() {
+                    if !seen_session_ids.insert(row.session_id.clone()) {
+                        continue;
+                    }
+                    let workspace = resolve_workspace(&row);
+                    threads.push(make_thread_entry(row, workspace));
+                }
 
-                for worktree_path_list in linked_worktree_queries {
+                // Load any legacy threads for any single linked wortree of this project group.
+                let mut linked_worktree_paths = HashSet::new();
+                for workspace in &group_workspaces {
+                    if workspace.read(cx).visible_worktrees(cx).count() != 1 {
+                        continue;
+                    }
+                    for snapshot in root_repository_snapshots(workspace, cx) {
+                        for linked_worktree in snapshot.linked_worktrees() {
+                            linked_worktree_paths.insert(linked_worktree.path.clone());
+                        }
+                    }
+                }
+                for path in linked_worktree_paths {
+                    let worktree_path_list = PathList::new(std::slice::from_ref(&path));
                     for row in thread_store
                         .read(cx)
                         .entries_for_path(&worktree_path_list)
@@ -887,67 +921,10 @@ impl Sidebar {
                         if !seen_session_ids.insert(row.session_id.clone()) {
                             continue;
                         }
-                        let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
-                        let worktrees =
-                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
-                        threads.push(ThreadEntry {
-                            metadata: row,
-                            icon,
-                            icon_from_external_svg,
-                            status: AgentThreadStatus::default(),
-                            workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
-                            is_live: false,
-                            is_background: false,
-                            is_title_generating: false,
-                            highlight_positions: Vec::new(),
-                            worktrees,
-                            diff_stats: DiffStats::default(),
-                        });
-                    }
-                }
-
-                // Load threads from main worktrees when a workspace in this
-                // group is itself a linked worktree checkout.
-                let main_repo_queries: Vec<PathList> = group
-                    .workspaces
-                    .iter()
-                    .flat_map(|ws| root_repository_snapshots(ws, cx))
-                    .filter(|snapshot| snapshot.is_linked_worktree())
-                    .map(|snapshot| {
-                        PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path))
-                    })
-                    .collect();
-
-                for main_repo_path_list in main_repo_queries {
-                    let folder_path_matches = thread_store
-                        .read(cx)
-                        .entries_for_path(&main_repo_path_list)
-                        .cloned();
-                    let main_worktree_path_matches = thread_store
-                        .read(cx)
-                        .entries_for_main_worktree_path(&main_repo_path_list)
-                        .cloned();
-
-                    for row in folder_path_matches.chain(main_worktree_path_matches) {
-                        if !seen_session_ids.insert(row.session_id.clone()) {
-                            continue;
-                        }
-                        let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
-                        let worktrees =
-                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
-                        threads.push(ThreadEntry {
-                            metadata: row,
-                            icon,
-                            icon_from_external_svg,
-                            status: AgentThreadStatus::default(),
-                            workspace: ThreadEntryWorkspace::Closed(main_repo_path_list.clone()),
-                            is_live: false,
-                            is_background: false,
-                            is_title_generating: false,
-                            highlight_positions: Vec::new(),
-                            worktrees,
-                            diff_stats: DiffStats::default(),
-                        });
+                        threads.push(make_thread_entry(
+                            row,
+                            ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
+                        ));
                     }
                 }
 
@@ -1051,9 +1028,8 @@ impl Sidebar {
 
                 project_header_indices.push(entries.len());
                 entries.push(ListEntry::ProjectHeader {
-                    path_list: path_list.clone(),
+                    key: group_key.clone(),
                     label,
-                    workspace: representative_workspace.clone(),
                     highlight_positions: workspace_highlight_positions,
                     has_running_threads,
                     waiting_thread_count,
@@ -1065,15 +1041,13 @@ impl Sidebar {
                     entries.push(thread.into());
                 }
             } else {
-                let is_draft_for_workspace = is_active
-                    && matches!(&self.active_entry, Some(ActiveEntry::Draft(_)))
-                    && self.active_entry_workspace() == Some(representative_workspace);
+                let is_draft_for_group = is_active
+                    && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws));
 
                 project_header_indices.push(entries.len());
                 entries.push(ListEntry::ProjectHeader {
-                    path_list: path_list.clone(),
+                    key: group_key.clone(),
                     label,
-                    workspace: representative_workspace.clone(),
                     highlight_positions: Vec::new(),
                     has_running_threads,
                     waiting_thread_count,
@@ -1084,25 +1058,61 @@ impl Sidebar {
                     continue;
                 }
 
-                // Emit "New Thread" entries for threadless workspaces
-                // and active drafts, right after the header.
-                for (workspace, worktrees) in &threadless_workspaces {
-                    entries.push(ListEntry::NewThread {
-                        path_list: path_list.clone(),
-                        workspace: workspace.clone(),
-                        worktrees: worktrees.clone(),
-                    });
+                // Emit a DraftThread entry when the active draft belongs to this group.
+                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);
+                        entries.push(ListEntry::DraftThread { worktrees });
+                    }
                 }
-                if is_draft_for_workspace
-                    && !threadless_workspaces
-                        .iter()
-                        .any(|(ws, _)| ws == representative_workspace)
+
+                // Emit a NewThread entry when:
+                // 1. The group has zero threads (convenient affordance).
+                // 2. The active workspace has linked worktrees but no threads
+                //    for the active workspace's specific set of worktrees.
+                let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty();
+                let active_ws_has_threadless_linked_worktrees = is_active
+                    && !is_draft_for_group
+                    && active_workspace.as_ref().is_some_and(|active_ws| {
+                        let ws_path_list = workspace_path_list(active_ws, cx);
+                        let has_linked_worktrees =
+                            !worktree_info_from_thread_paths(&ws_path_list, &group_key).is_empty();
+                        if !has_linked_worktrees {
+                            return false;
+                        }
+                        let thread_store = ThreadMetadataStore::global(cx);
+                        let has_threads_for_ws = thread_store
+                            .read(cx)
+                            .entries_for_path(&ws_path_list)
+                            .next()
+                            .is_some()
+                            || thread_store
+                                .read(cx)
+                                .entries_for_main_worktree_path(&ws_path_list)
+                                .next()
+                                .is_some();
+                        !has_threads_for_ws
+                    });
+
+                if !is_draft_for_group
+                    && (group_has_no_threads || active_ws_has_threadless_linked_worktrees)
                 {
-                    let ws_path_list = workspace_path_list(representative_workspace, cx);
-                    let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups);
+                    let worktrees = if active_ws_has_threadless_linked_worktrees {
+                        active_workspace
+                            .as_ref()
+                            .map(|ws| {
+                                worktree_info_from_thread_paths(
+                                    &workspace_path_list(ws, cx),
+                                    &group_key,
+                                )
+                            })
+                            .unwrap_or_default()
+                    } else {
+                        Vec::new()
+                    };
                     entries.push(ListEntry::NewThread {
-                        path_list: path_list.clone(),
-                        workspace: representative_workspace.clone(),
+                        key: group_key.clone(),
                         worktrees,
                     });
                 }
@@ -1148,7 +1158,7 @@ impl Sidebar {
 
                 if total > DEFAULT_THREADS_SHOWN {
                     entries.push(ListEntry::ViewMore {
-                        path_list: path_list.clone(),
+                        key: group_key.clone(),
                         is_fully_expanded,
                     });
                 }
@@ -1236,9 +1246,8 @@ impl Sidebar {
 
         let rendered = match entry {
             ListEntry::ProjectHeader {
-                path_list,
+                key,
                 label,
-                workspace,
                 highlight_positions,
                 has_running_threads,
                 waiting_thread_count,
@@ -1246,9 +1255,8 @@ impl Sidebar {
             } => self.render_project_header(
                 ix,
                 false,
-                path_list,
+                key,
                 label,
-                workspace,
                 highlight_positions,
                 *has_running_threads,
                 *waiting_thread_count,
@@ -1258,22 +1266,15 @@ impl Sidebar {
             ),
             ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
             ListEntry::ViewMore {
-                path_list,
+                key,
                 is_fully_expanded,
-            } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
-            ListEntry::NewThread {
-                path_list,
-                workspace,
-                worktrees,
-            } => self.render_new_thread(
-                ix,
-                path_list,
-                workspace,
-                is_active,
-                worktrees,
-                is_selected,
-                cx,
-            ),
+            } => self.render_view_more(ix, key.path_list(), *is_fully_expanded, is_selected, cx),
+            ListEntry::DraftThread { worktrees, .. } => {
+                self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
+            }
+            ListEntry::NewThread { key, worktrees, .. } => {
+                self.render_new_thread(ix, key, worktrees, is_selected, cx)
+            }
         };
 
         if is_group_header_after_first {
@@ -1291,13 +1292,9 @@ impl Sidebar {
     fn render_remote_project_icon(
         &self,
         ix: usize,
-        workspace: &Entity<Workspace>,
-        cx: &mut Context<Self>,
+        host: Option<&RemoteConnectionOptions>,
     ) -> Option<AnyElement> {
-        let project = workspace.read(cx).project().read(cx);
-        let remote_connection_options = project.remote_connection_options(cx)?;
-
-        let remote_icon_per_type = match remote_connection_options {
+        let remote_icon_per_type = match host? {
             RemoteConnectionOptions::Wsl(_) => IconName::Linux,
             RemoteConnectionOptions::Docker(_) => IconName::Box,
             _ => IconName::Server,
@@ -1320,9 +1317,8 @@ impl Sidebar {
         &self,
         ix: usize,
         is_sticky: bool,
-        path_list: &PathList,
+        key: &ProjectGroupKey,
         label: &SharedString,
-        workspace: &Entity<Workspace>,
         highlight_positions: &[usize],
         has_running_threads: bool,
         waiting_thread_count: usize,
@@ -1330,6 +1326,9 @@ impl Sidebar {
         is_focused: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
+        let path_list = key.path_list();
+        let host = key.host();
+
         let id_prefix = if is_sticky { "sticky-" } else { "" };
         let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
         let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
@@ -1342,16 +1341,15 @@ impl Sidebar {
             (IconName::ChevronDown, "Collapse Project")
         };
 
-        let has_new_thread_entry = self
-            .contents
-            .entries
-            .get(ix + 1)
-            .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
+        let has_new_thread_entry = self.contents.entries.get(ix + 1).is_some_and(|entry| {
+            matches!(
+                entry,
+                ListEntry::NewThread { .. } | ListEntry::DraftThread { .. }
+            )
+        });
         let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
 
-        let workspace_for_remove = workspace.clone();
-        let workspace_for_menu = workspace.clone();
-        let workspace_for_open = workspace.clone();
+        let workspace = self.workspace_for_group(path_list, cx);
 
         let path_list_for_toggle = path_list.clone();
         let path_list_for_collapse = path_list.clone();
@@ -1408,7 +1406,7 @@ impl Sidebar {
                     )
                     .child(label)
                     .when_some(
-                        self.render_remote_project_icon(ix, workspace, cx),
+                        self.render_remote_project_icon(ix, host.as_ref()),
                         |this, icon| this.child(icon),
                     )
                     .when(is_collapsed, |this| {
@@ -1452,13 +1450,13 @@ impl Sidebar {
                     .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
                         cx.stop_propagation();
                     })
-                    .child(self.render_project_header_menu(
-                        ix,
-                        id_prefix,
-                        &workspace_for_menu,
-                        &workspace_for_remove,
-                        cx,
-                    ))
+                    .when_some(workspace, |this, workspace| {
+                        this.child(
+                            self.render_project_header_menu(
+                                ix, id_prefix, &workspace, &workspace, cx,
+                            ),
+                        )
+                    })
                     .when(view_more_expanded && !is_collapsed, |this| {
                         this.child(
                             IconButton::new(
@@ -1480,52 +1478,56 @@ impl Sidebar {
                             })),
                         )
                     })
-                    .when(show_new_thread_button, |this| {
-                        this.child(
-                            IconButton::new(
-                                SharedString::from(format!(
-                                    "{id_prefix}project-header-new-thread-{ix}",
+                    .when(
+                        show_new_thread_button && workspace_for_new_thread.is_some(),
+                        |this| {
+                            let workspace_for_new_thread =
+                                workspace_for_new_thread.clone().unwrap();
+                            let path_list_for_new_thread = path_list_for_new_thread.clone();
+                            this.child(
+                                IconButton::new(
+                                    SharedString::from(format!(
+                                        "{id_prefix}project-header-new-thread-{ix}",
+                                    )),
+                                    IconName::Plus,
+                                )
+                                .icon_size(IconSize::Small)
+                                .tooltip(Tooltip::text("New Thread"))
+                                .on_click(cx.listener(
+                                    move |this, _, window, cx| {
+                                        this.collapsed_groups.remove(&path_list_for_new_thread);
+                                        this.selection = None;
+                                        this.create_new_thread(
+                                            &workspace_for_new_thread,
+                                            window,
+                                            cx,
+                                        );
+                                    },
                                 )),
-                                IconName::Plus,
                             )
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::text("New Thread"))
-                            .on_click(cx.listener({
-                                let workspace_for_new_thread = workspace_for_new_thread.clone();
-                                let path_list_for_new_thread = path_list_for_new_thread.clone();
-                                move |this, _, window, cx| {
-                                    // Uncollapse the group if collapsed so
-                                    // the new-thread entry becomes visible.
-                                    this.collapsed_groups.remove(&path_list_for_new_thread);
-                                    this.selection = None;
-                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
-                                }
-                            })),
-                        )
-                    })
+                        },
+                    )
             })
             .when(!is_active, |this| {
+                let path_list_for_open = path_list.clone();
                 this.cursor_pointer()
                     .hover(|s| s.bg(hover_color))
-                    .tooltip(Tooltip::text("Activate Workspace"))
-                    .on_click(cx.listener({
-                        move |this, _, window, cx| {
-                            this.active_entry =
-                                Some(ActiveEntry::Draft(workspace_for_open.clone()));
+                    .tooltip(Tooltip::text("Open Workspace"))
+                    .on_click(cx.listener(move |this, _, window, cx| {
+                        if let Some(workspace) = this.workspace_for_group(&path_list_for_open, cx) {
+                            this.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
                             if let Some(multi_workspace) = this.multi_workspace.upgrade() {
                                 multi_workspace.update(cx, |multi_workspace, cx| {
-                                    multi_workspace.activate(
-                                        workspace_for_open.clone(),
-                                        window,
-                                        cx,
-                                    );
+                                    multi_workspace.activate(workspace.clone(), window, cx);
                                 });
                             }
-                            if AgentPanel::is_visible(&workspace_for_open, cx) {
-                                workspace_for_open.update(cx, |workspace, cx| {
+                            if AgentPanel::is_visible(&workspace, cx) {
+                                workspace.update(cx, |workspace, cx| {
                                     workspace.focus_panel::<AgentPanel>(window, cx);
                                 });
                             }
+                        } else {
+                            this.open_workspace_for_group(&path_list_for_open, window, cx);
                         }
                     }))
             })
@@ -1720,9 +1722,8 @@ impl Sidebar {
         }
 
         let ListEntry::ProjectHeader {
-            path_list,
+            key,
             label,
-            workspace,
             highlight_positions,
             has_running_threads,
             waiting_thread_count,
@@ -1738,9 +1739,8 @@ impl Sidebar {
         let header_element = self.render_project_header(
             header_idx,
             true,
-            &path_list,
+            key,
             &label,
-            workspace,
             &highlight_positions,
             *has_running_threads,
             *waiting_thread_count,
@@ -1961,8 +1961,8 @@ impl Sidebar {
         };
 
         match entry {
-            ListEntry::ProjectHeader { path_list, .. } => {
-                let path_list = path_list.clone();
+            ListEntry::ProjectHeader { key, .. } => {
+                let path_list = key.path_list().clone();
                 self.toggle_collapse(&path_list, window, cx);
             }
             ListEntry::Thread(thread) => {
@@ -1983,11 +1983,11 @@ impl Sidebar {
                 }
             }
             ListEntry::ViewMore {
-                path_list,
+                key,
                 is_fully_expanded,
                 ..
             } => {
-                let path_list = path_list.clone();
+                let path_list = key.path_list().clone();
                 if *is_fully_expanded {
                     self.expanded_groups.remove(&path_list);
                 } else {
@@ -1997,9 +1997,16 @@ impl Sidebar {
                 self.serialize(cx);
                 self.update_entries(cx);
             }
-            ListEntry::NewThread { workspace, .. } => {
-                let workspace = workspace.clone();
-                self.create_new_thread(&workspace, window, cx);
+            ListEntry::DraftThread { .. } => {
+                // Already active — nothing to do.
+            }
+            ListEntry::NewThread { key, .. } => {
+                let path_list = key.path_list().clone();
+                if let Some(workspace) = self.workspace_for_group(&path_list, cx) {
+                    self.create_new_thread(&workspace, window, cx);
+                } else {
+                    self.open_workspace_for_group(&path_list, window, cx);
+                }
             }
         }
     }
@@ -2251,9 +2258,9 @@ impl Sidebar {
         let Some(ix) = self.selection else { return };
 
         match self.contents.entries.get(ix) {
-            Some(ListEntry::ProjectHeader { path_list, .. }) => {
-                if self.collapsed_groups.contains(path_list) {
-                    let path_list = path_list.clone();
+            Some(ListEntry::ProjectHeader { key, .. }) => {
+                if self.collapsed_groups.contains(key.path_list()) {
+                    let path_list = key.path_list().clone();
                     self.collapsed_groups.remove(&path_list);
                     self.update_entries(cx);
                 } else if ix + 1 < self.contents.entries.len() {
@@ -2275,23 +2282,23 @@ impl Sidebar {
         let Some(ix) = self.selection else { return };
 
         match self.contents.entries.get(ix) {
-            Some(ListEntry::ProjectHeader { path_list, .. }) => {
-                if !self.collapsed_groups.contains(path_list) {
-                    let path_list = path_list.clone();
-                    self.collapsed_groups.insert(path_list);
+            Some(ListEntry::ProjectHeader { key, .. }) => {
+                if !self.collapsed_groups.contains(key.path_list()) {
+                    self.collapsed_groups.insert(key.path_list().clone());
                     self.update_entries(cx);
                 }
             }
             Some(
-                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+                ListEntry::Thread(_)
+                | ListEntry::ViewMore { .. }
+                | ListEntry::NewThread { .. }
+                | ListEntry::DraftThread { .. },
             ) => {
                 for i in (0..ix).rev() {
-                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
-                        self.contents.entries.get(i)
+                    if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
                     {
-                        let path_list = path_list.clone();
                         self.selection = Some(i);
-                        self.collapsed_groups.insert(path_list);
+                        self.collapsed_groups.insert(key.path_list().clone());
                         self.update_entries(cx);
                         break;
                     }
@@ -2313,7 +2320,10 @@ impl Sidebar {
         let header_ix = match self.contents.entries.get(ix) {
             Some(ListEntry::ProjectHeader { .. }) => Some(ix),
             Some(
-                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+                ListEntry::Thread(_)
+                | ListEntry::ViewMore { .. }
+                | ListEntry::NewThread { .. }
+                | ListEntry::DraftThread { .. },
             ) => (0..ix).rev().find(|&i| {
                 matches!(
                     self.contents.entries.get(i),
@@ -2324,15 +2334,14 @@ impl Sidebar {
         };
 
         if let Some(header_ix) = header_ix {
-            if let Some(ListEntry::ProjectHeader { path_list, .. }) =
-                self.contents.entries.get(header_ix)
+            if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
             {
-                let path_list = path_list.clone();
-                if self.collapsed_groups.contains(&path_list) {
-                    self.collapsed_groups.remove(&path_list);
+                let path_list = key.path_list();
+                if self.collapsed_groups.contains(path_list) {
+                    self.collapsed_groups.remove(path_list);
                 } else {
                     self.selection = Some(header_ix);
-                    self.collapsed_groups.insert(path_list);
+                    self.collapsed_groups.insert(path_list.clone());
                 }
                 self.update_entries(cx);
             }
@@ -2346,8 +2355,8 @@ impl Sidebar {
         cx: &mut Context<Self>,
     ) {
         for entry in &self.contents.entries {
-            if let ListEntry::ProjectHeader { path_list, .. } = entry {
-                self.collapsed_groups.insert(path_list.clone());
+            if let ListEntry::ProjectHeader { key, .. } = entry {
+                self.collapsed_groups.insert(key.path_list().clone());
             }
         }
         self.update_entries(cx);
@@ -2402,17 +2411,18 @@ impl Sidebar {
             });
 
             // Find the workspace that owns this thread's project group by
-            // walking backwards to the nearest ProjectHeader. We must use
-            // *this* workspace (not the active workspace) because the user
-            // might be archiving a thread in a non-active group.
+            // walking backwards to the nearest ProjectHeader and looking up
+            // an open workspace for that group's path_list.
             let group_workspace = current_pos.and_then(|pos| {
-                self.contents.entries[..pos]
-                    .iter()
-                    .rev()
-                    .find_map(|e| match e {
-                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
-                        _ => None,
-                    })
+                let path_list =
+                    self.contents.entries[..pos]
+                        .iter()
+                        .rev()
+                        .find_map(|e| match e {
+                            ListEntry::ProjectHeader { key, .. } => Some(key.path_list()),
+                            _ => None,
+                        })?;
+                self.workspace_for_group(path_list, cx)
             });
 
             let next_thread = current_pos.and_then(|pos| {
@@ -2527,28 +2537,26 @@ impl Sidebar {
             .insert(session_id.clone(), Utc::now());
     }
 
-    fn mru_threads_for_switcher(&self, _cx: &App) -> Vec<ThreadSwitcherEntry> {
+    fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
         let mut current_header_label: Option<SharedString> = None;
-        let mut current_header_workspace: Option<Entity<Workspace>> = None;
+        let mut current_header_path_list: Option<PathList> = None;
         let mut entries: Vec<ThreadSwitcherEntry> = self
             .contents
             .entries
             .iter()
             .filter_map(|entry| match entry {
-                ListEntry::ProjectHeader {
-                    label, workspace, ..
-                } => {
+                ListEntry::ProjectHeader { label, key, .. } => {
                     current_header_label = Some(label.clone());
-                    current_header_workspace = Some(workspace.clone());
+                    current_header_path_list = Some(key.path_list().clone());
                     None
                 }
                 ListEntry::Thread(thread) => {
                     let workspace = match &thread.workspace {
-                        ThreadEntryWorkspace::Open(workspace) => workspace.clone(),
-                        ThreadEntryWorkspace::Closed(_) => {
-                            current_header_workspace.as_ref()?.clone()
-                        }
-                    };
+                        ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
+                        ThreadEntryWorkspace::Closed(_) => current_header_path_list
+                            .as_ref()
+                            .and_then(|pl| self.workspace_for_group(pl, cx)),
+                    }?;
                     let notified = self
                         .contents
                         .is_thread_notified(&thread.metadata.session_id);

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -88,14 +88,18 @@ fn setup_sidebar(
     sidebar
 }
 
-async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) {
+async fn save_n_test_threads(
+    count: u32,
+    project: &Entity<project::Project>,
+    cx: &mut gpui::VisualTestContext,
+) {
     for i in 0..count {
         save_thread_metadata(
             acp::SessionId::new(Arc::from(format!("thread-{}", i))),
             format!("Thread {}", i + 1).into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
             None,
-            path_list.clone(),
+            project,
             cx,
         )
     }
@@ -104,7 +108,7 @@ async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::Vi
 
 async fn save_test_thread_metadata(
     session_id: &acp::SessionId,
-    path_list: PathList,
+    project: &Entity<project::Project>,
     cx: &mut TestAppContext,
 ) {
     save_thread_metadata(
@@ -112,7 +116,7 @@ async fn save_test_thread_metadata(
         "Test".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
         None,
-        path_list,
+        project,
         cx,
     )
 }
@@ -120,7 +124,7 @@ async fn save_test_thread_metadata(
 async fn save_named_thread_metadata(
     session_id: &str,
     title: &str,
-    path_list: &PathList,
+    project: &Entity<project::Project>,
     cx: &mut gpui::VisualTestContext,
 ) {
     save_thread_metadata(
@@ -128,7 +132,7 @@ async fn save_named_thread_metadata(
         SharedString::from(title.to_string()),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
         None,
-        path_list.clone(),
+        project,
         cx,
     );
     cx.run_until_parked();
@@ -139,21 +143,31 @@ fn save_thread_metadata(
     title: SharedString,
     updated_at: DateTime<Utc>,
     created_at: Option<DateTime<Utc>>,
-    path_list: PathList,
+    project: &Entity<project::Project>,
     cx: &mut TestAppContext,
 ) {
-    let metadata = ThreadMetadata {
-        session_id,
-        agent_id: agent::ZED_AGENT_ID.clone(),
-        title,
-        updated_at,
-        created_at,
-        folder_paths: path_list,
-        main_worktree_paths: PathList::default(),
-        archived: false,
-    };
     cx.update(|cx| {
-        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx))
+        let (folder_paths, main_worktree_paths) = {
+            let project_ref = project.read(cx);
+            let paths: Vec<Arc<Path>> = project_ref
+                .visible_worktrees(cx)
+                .map(|worktree| worktree.read(cx).abs_path())
+                .collect();
+            let folder_paths = PathList::new(&paths);
+            let main_worktree_paths = project_ref.project_group_key(cx).path_list().clone();
+            (folder_paths, main_worktree_paths)
+        };
+        let metadata = ThreadMetadata {
+            session_id,
+            agent_id: agent::ZED_AGENT_ID.clone(),
+            title,
+            updated_at,
+            created_at,
+            folder_paths,
+            main_worktree_paths,
+            archived: false,
+        };
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
     });
     cx.run_until_parked();
 }
@@ -193,11 +207,11 @@ fn visible_entries_as_strings(
                 match entry {
                     ListEntry::ProjectHeader {
                         label,
-                        path_list,
+                        key,
                         highlight_positions: _,
                         ..
                     } => {
-                        let icon = if sidebar.collapsed_groups.contains(path_list) {
+                        let icon = if sidebar.collapsed_groups.contains(key.path_list()) {
                             ">"
                         } else {
                             "v"
@@ -248,6 +262,22 @@ fn visible_entries_as_strings(
                             format!("  + View More{}", selected)
                         }
                     }
+                    ListEntry::DraftThread { worktrees, .. } => {
+                        let worktree = if worktrees.is_empty() {
+                            String::new()
+                        } else {
+                            let mut seen = Vec::new();
+                            let mut chips = Vec::new();
+                            for wt in worktrees {
+                                if !seen.contains(&wt.name) {
+                                    seen.push(wt.name.clone());
+                                    chips.push(format!("{{{}}}", wt.name));
+                                }
+                            }
+                            format!(" {}", chips.join(", "))
+                        };
+                        format!("  [~ Draft{}]{}", worktree, selected)
+                    }
                     ListEntry::NewThread { worktrees, .. } => {
                         let worktree = if worktrees.is_empty() {
                             String::new()
@@ -274,11 +304,14 @@ fn visible_entries_as_strings(
 async fn test_serialization_round_trip(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(3, &path_list, cx).await;
+    save_n_test_threads(3, &project, cx).await;
+
+    let path_list = project.read_with(cx, |project, cx| {
+        project.project_group_key(cx).path_list().clone()
+    });
 
     // Set a custom width, collapse the group, and expand "View More".
     sidebar.update_in(cx, |sidebar, window, cx| {
@@ -437,17 +470,15 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
 async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     save_thread_metadata(
         acp::SessionId::new(Arc::from("thread-1")),
         "Fix crash in project panel".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
         None,
-        path_list.clone(),
+        &project,
         cx,
     );
 
@@ -456,7 +487,7 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
         "Add inline diff view".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
         None,
-        path_list,
+        &project,
         cx,
     );
     cx.run_until_parked();
@@ -478,18 +509,16 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
 async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
     let project = init_test_project("/project-a", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
     // Single workspace with a thread
-    let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
     save_thread_metadata(
         acp::SessionId::new(Arc::from("thread-a1")),
         "Thread A1".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
         None,
-        path_list,
+        &project,
         cx,
     );
     cx.run_until_parked();
@@ -530,11 +559,10 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
 async fn test_view_more_pagination(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(12, &path_list, cx).await;
+    save_n_test_threads(12, &project, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -557,12 +585,15 @@ async fn test_view_more_pagination(cx: &mut TestAppContext) {
 async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
     // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
-    save_n_test_threads(17, &path_list, cx).await;
+    save_n_test_threads(17, &project, cx).await;
+
+    let path_list = project.read_with(cx, |project, cx| {
+        project.project_group_key(cx).path_list().clone()
+    });
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -629,11 +660,14 @@ async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
 async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(1, &path_list, cx).await;
+    save_n_test_threads(1, &project, cx).await;
+
+    let path_list = project.read_with(cx, |project, cx| {
+        project.project_group_key(cx).path_list().clone()
+    });
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -685,9 +719,8 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
         s.contents.entries = vec![
             // Expanded project header
             ListEntry::ProjectHeader {
-                path_list: expanded_path.clone(),
+                key: project::ProjectGroupKey::new(None, expanded_path.clone()),
                 label: "expanded-project".into(),
-                workspace: workspace.clone(),
                 highlight_positions: Vec::new(),
                 has_running_threads: false,
                 waiting_thread_count: 0,
@@ -809,14 +842,13 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
             }),
             // View More entry
             ListEntry::ViewMore {
-                path_list: expanded_path.clone(),
+                key: project::ProjectGroupKey::new(None, expanded_path.clone()),
                 is_fully_expanded: false,
             },
             // Collapsed project header
             ListEntry::ProjectHeader {
-                path_list: collapsed_path.clone(),
+                key: project::ProjectGroupKey::new(None, collapsed_path.clone()),
                 label: "collapsed-project".into(),
-                workspace: workspace.clone(),
                 highlight_positions: Vec::new(),
                 has_running_threads: false,
                 waiting_thread_count: 0,
@@ -872,11 +904,10 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
 async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(3, &path_list, cx).await;
+    save_n_test_threads(3, &project, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -932,11 +963,10 @@ async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
 async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(3, &path_list, cx).await;
+    save_n_test_threads(3, &project, cx).await;
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
 
@@ -987,11 +1017,10 @@ async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext)
 async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(1, &path_list, cx).await;
+    save_n_test_threads(1, &project, cx).await;
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
 
@@ -1029,11 +1058,10 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
 async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(8, &path_list, cx).await;
+    save_n_test_threads(8, &project, cx).await;
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
 
@@ -1064,11 +1092,10 @@ async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
 async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(1, &path_list, cx).await;
+    save_n_test_threads(1, &project, cx).await;
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
 
@@ -1109,11 +1136,10 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
 async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(1, &path_list, cx).await;
+    save_n_test_threads(1, &project, cx).await;
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
 
@@ -1177,11 +1203,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
 async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-    save_n_test_threads(1, &path_list, cx).await;
+    save_n_test_threads(1, &project, cx).await;
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
 
@@ -1254,15 +1279,13 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     // Open thread A and keep it generating.
     let connection = StubAgentConnection::new();
     open_thread_with_connection(&panel, connection.clone(), cx);
     send_message(&panel, cx);
 
     let session_id_a = active_session_id(&panel, cx);
-    save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await;
+    save_test_thread_metadata(&session_id_a, &project, cx).await;
 
     cx.update(|_, cx| {
         connection.send_update(
@@ -1281,7 +1304,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
     send_message(&panel, cx);
 
     let session_id_b = active_session_id(&panel, cx);
-    save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await;
+    save_test_thread_metadata(&session_id_b, &project, cx).await;
 
     cx.run_until_parked();
 
@@ -1300,15 +1323,13 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
     let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
-    let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
     // Open thread on workspace A and keep it generating.
     let connection_a = StubAgentConnection::new();
     open_thread_with_connection(&panel_a, connection_a.clone(), cx);
     send_message(&panel_a, cx);
 
     let session_id_a = active_session_id(&panel_a, cx);
-    save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
+    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 
     cx.update(|_, cx| {
         connection_a.send_update(
@@ -1358,11 +1379,9 @@ fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualT
 async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     for (id, title, hour) in [
         ("t-1", "Fix crash in project panel", 3),
         ("t-2", "Add inline diff view", 2),
@@ -1373,7 +1392,7 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
             None,
-            path_list.clone(),
+            &project,
             cx,
         );
     }
@@ -1411,17 +1430,15 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
     // Search should match case-insensitively so they can still find it.
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     save_thread_metadata(
         acp::SessionId::new(Arc::from("thread-1")),
         "Fix Crash In Project Panel".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
         None,
-        path_list,
+        &project,
         cx,
     );
     cx.run_until_parked();
@@ -1453,18 +1470,16 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
     // to dismiss the filter and see the full list again.
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
         save_thread_metadata(
             acp::SessionId::new(Arc::from(id)),
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
             None,
-            path_list.clone(),
+            &project,
             cx,
         )
     }
@@ -1502,11 +1517,9 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
 async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
     let project_a = init_test_project("/project-a", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
     for (id, title, hour) in [
         ("a1", "Fix bug in sidebar", 2),
         ("a2", "Add tests for editor", 1),
@@ -1516,7 +1529,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
             None,
-            path_list_a.clone(),
+            &project_a,
             cx,
         )
     }
@@ -1527,7 +1540,8 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
     });
     cx.run_until_parked();
 
-    let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+    let project_b =
+        multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone());
 
     for (id, title, hour) in [
         ("b1", "Refactor sidebar layout", 3),
@@ -1538,7 +1552,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
             None,
-            path_list_b.clone(),
+            &project_b,
             cx,
         )
     }
@@ -1584,11 +1598,9 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
 async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
     let project_a = init_test_project("/alpha-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
-
     for (id, title, hour) in [
         ("a1", "Fix bug in sidebar", 2),
         ("a2", "Add tests for editor", 1),
@@ -1598,7 +1610,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
             None,
-            path_list_a.clone(),
+            &project_a,
             cx,
         )
     }
@@ -1609,7 +1621,8 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
     });
     cx.run_until_parked();
 
-    let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+    let project_b =
+        multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone());
 
     for (id, title, hour) in [
         ("b1", "Refactor sidebar layout", 3),
@@ -1620,7 +1633,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
             None,
-            path_list_b.clone(),
+            &project_b,
             cx,
         )
     }
@@ -1686,11 +1699,9 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
 async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     // Create 8 threads. The oldest one has a unique name and will be
     // behind View More (only 5 shown by default).
     for i in 0..8u32 {
@@ -1704,7 +1715,7 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
             None,
-            path_list.clone(),
+            &project,
             cx,
         )
     }
@@ -1738,17 +1749,15 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
 async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     save_thread_metadata(
         acp::SessionId::new(Arc::from("thread-1")),
         "Important thread".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
         None,
-        path_list,
+        &project,
         cx,
     );
     cx.run_until_parked();
@@ -1779,11 +1788,9 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte
 async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     for (id, title, hour) in [
         ("t-1", "Fix crash in panel", 3),
         ("t-2", "Fix lint warnings", 2),
@@ -1794,7 +1801,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
             title.into(),
             chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
             None,
-            path_list.clone(),
+            &project,
             cx,
         )
     }
@@ -1841,7 +1848,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
 async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
     multi_workspace.update_in(cx, |mw, window, cx| {
@@ -1849,14 +1856,12 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
     });
     cx.run_until_parked();
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     save_thread_metadata(
         acp::SessionId::new(Arc::from("hist-1")),
         "Historical Thread".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
         None,
-        path_list,
+        &project,
         cx,
     );
     cx.run_until_parked();
@@ -1899,17 +1904,15 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
 async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
     let project = init_test_project("/my-project", cx).await;
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     save_thread_metadata(
         acp::SessionId::new(Arc::from("t-1")),
         "Thread A".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
         None,
-        path_list.clone(),
+        &project,
         cx,
     );
 
@@ -1918,7 +1921,7 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo
         "Thread B".into(),
         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
         None,
-        path_list,
+        &project,
         cx,
     );
 
@@ -1966,8 +1969,6 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     let connection = StubAgentConnection::new();
     connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
         acp::ContentChunk::new("Hi there!".into()),
@@ -1976,7 +1977,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
     send_message(&panel, cx);
 
     let session_id = active_session_id(&panel, cx);
-    save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
+    save_test_thread_metadata(&session_id, &project, cx).await;
     cx.run_until_parked();
 
     assert_eq!(
@@ -2014,8 +2015,6 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
     let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
-    let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
     // Save a thread so it appears in the list.
     let connection_a = StubAgentConnection::new();
     connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2024,7 +2023,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
     open_thread_with_connection(&panel_a, connection_a, cx);
     send_message(&panel_a, cx);
     let session_id_a = active_session_id(&panel_a, cx);
-    save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
+    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
 
     // Add a second workspace with its own agent panel.
     let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
@@ -2099,8 +2098,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
     open_thread_with_connection(&panel_b, connection_b, cx);
     send_message(&panel_b, cx);
     let session_id_b = active_session_id(&panel_b, cx);
-    let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
-    save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
+    save_test_thread_metadata(&session_id_b, &project_b, cx).await;
     cx.run_until_parked();
 
     // Workspace A is currently active. Click a thread in workspace B,
@@ -2161,7 +2159,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
     open_thread_with_connection(&panel_b, connection_b2, cx);
     send_message(&panel_b, cx);
     let session_id_b2 = active_session_id(&panel_b, cx);
-    save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await;
+    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
@@ -2243,8 +2241,6 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
-    let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
     // Start a thread and send a message so it has history.
     let connection = StubAgentConnection::new();
     connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2253,7 +2249,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
     open_thread_with_connection(&panel, connection, cx);
     send_message(&panel, cx);
     let session_id = active_session_id(&panel, cx);
-    save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await;
+    save_test_thread_metadata(&session_id, &project, cx).await;
     cx.run_until_parked();
 
     // Verify the thread appears in the sidebar.
@@ -2287,9 +2283,15 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
     // 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!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [project-a, project-b]", "  Hello *",]
+        vec![
+            "v [project-a, project-b]", //
+            "  Hello *",
+            "v [project-a]",
+        ]
     );
 
     // The "New Thread" button must still be clickable (not stuck in
@@ -2334,8 +2336,6 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     // Create a non-empty thread (has messages).
     let connection = StubAgentConnection::new();
     connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2345,7 +2345,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
     send_message(&panel, cx);
 
     let session_id = active_session_id(&panel, cx);
-    save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
+    save_test_thread_metadata(&session_id, &project, cx).await;
     cx.run_until_parked();
 
     assert_eq!(
@@ -2365,8 +2365,8 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [my-project]", "  [+ New Thread]", "  Hello *"],
-        "After Cmd-N the sidebar should show a highlighted New Thread entry"
+        vec!["v [my-project]", "  [~ Draft]", "  Hello *"],
+        "After Cmd-N the sidebar should show a highlighted Draft entry"
     );
 
     sidebar.read_with(cx, |sidebar, _cx| {
@@ -2385,8 +2385,6 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
-    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
     // Create a saved thread so the workspace has history.
     let connection = StubAgentConnection::new();
     connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2395,7 +2393,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
     open_thread_with_connection(&panel, connection, cx);
     send_message(&panel, cx);
     let saved_session_id = active_session_id(&panel, cx);
-    save_test_thread_metadata(&saved_session_id, path_list.clone(), cx).await;
+    save_test_thread_metadata(&saved_session_id, &project, cx).await;
     cx.run_until_parked();
 
     assert_eq!(
@@ -2412,8 +2410,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec!["v [my-project]", "  [+ New Thread]", "  Hello *"],
-        "Draft with a server session should still show as [+ New Thread]"
+        vec!["v [my-project]", "  [~ Draft]", "  Hello *"],
     );
 
     let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
@@ -2503,17 +2500,12 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
     send_message(&worktree_panel, cx);
 
     let session_id = active_session_id(&worktree_panel, cx);
-    let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
-    save_test_thread_metadata(&session_id, wt_path_list, cx).await;
+    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
     cx.run_until_parked();
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            "v [project]",
-            "  [+ New Thread]",
-            "  Hello {wt-feature-a} *"
-        ]
+        vec!["v [project]", "  Hello {wt-feature-a} *"]
     );
 
     // Simulate Cmd-N in the worktree workspace.
@@ -2529,12 +2521,11 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
         visible_entries_as_strings(&sidebar, cx),
         vec![
             "v [project]",
-            "  [+ New Thread]",
-            "  [+ New Thread {wt-feature-a}]",
+            "  [~ Draft {wt-feature-a}]",
             "  Hello {wt-feature-a} *"
         ],
         "After Cmd-N in an absorbed worktree, the sidebar should show \
-             a highlighted New Thread entry under the main repo header"
+             a highlighted Draft entry under the main repo header"
     );
 
     sidebar.read_with(cx, |sidebar, _cx| {
@@ -2586,14 +2577,17 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
         .update(cx, |project, cx| project.git_scans_complete(cx))
         .await;
 
+    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
+    worktree_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+
     let (multi_workspace, cx) =
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
-    let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
-    save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
-    save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
+    save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
+    save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -2615,13 +2609,17 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
         .update(cx, |project, cx| project.git_scans_complete(cx))
         .await;
 
+    let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
+    worktree_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+
     let (multi_workspace, cx) =
         cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
     // Save a thread against a worktree path that doesn't exist yet.
-    let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
-    save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
+    save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -2650,11 +2648,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
 
     assert_eq!(
         visible_entries_as_strings(&sidebar, cx),
-        vec![
-            "v [project]",
-            "  [+ New Thread]",
-            "  Worktree Thread {rosewood}",
-        ]
+        vec!["v [project]", "  Worktree Thread {rosewood}",]
     );
 }
 
@@ -2714,10 +2708,8 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
     });
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
-    let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
-    let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
-    save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
-    save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
+    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
+    save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -2748,7 +2740,6 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
         visible_entries_as_strings(&sidebar, cx),
         vec![
             "v [project]",
-            "  [+ New Thread]",
             "  Thread A {wt-feature-a}",
             "  Thread B {wt-feature-b}",
         ]
@@ -2813,8 +2804,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
     // Only save a thread for workspace A.
-    let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
-    save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
+    save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -2894,11 +2884,7 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
     // Save a thread under the same paths as the workspace roots.
-    let thread_paths = PathList::new(&[
-        std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
-        std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"),
-    ]);
-    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await;
+    save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -2971,11 +2957,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
     let sidebar = setup_sidebar(&multi_workspace, cx);
 
     // Thread with roots in both repos' "olivetti" worktrees.
-    let thread_paths = PathList::new(&[
-        std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
-        std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"),
-    ]);
-    save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await;
+    save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
 
     multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
     cx.run_until_parked();
@@ -3070,8 +3052,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
     let session_id = active_session_id(&worktree_panel, cx);
 
     // Save metadata so the sidebar knows about this thread.
-    let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
-    save_test_thread_metadata(&session_id, wt_paths, cx).await;
+    save_test_thread_metadata(&session_id, &worktree_project, cx).await;
 
     // Keep the thread generating by sending a chunk without ending
     // the turn.
@@ -3091,7 +3072,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
         entries,
         vec![
             "v [project]",
-            "  [+ New Thread]",
+            "  [~ Draft]",
             "  Hello {wt-feature-a} * (running)",
         ]
     );

crates/workspace/src/multi_workspace.rs 🔗

@@ -474,6 +474,26 @@ impl MultiWorkspace {
         self.project_group_keys.iter()
     }
 
+    /// Returns the project groups, ordered by most recently added.
+    pub fn project_groups(
+        &self,
+        cx: &App,
+    ) -> impl Iterator<Item = (ProjectGroupKey, Vec<Entity<Workspace>>)> {
+        let mut groups = self
+            .project_group_keys
+            .iter()
+            .rev()
+            .map(|key| (key.clone(), Vec::new()))
+            .collect::<Vec<_>>();
+        for workspace in &self.workspaces {
+            let key = workspace.read(cx).project_group_key(cx);
+            if let Some((_, workspaces)) = groups.iter_mut().find(|(k, _)| k == &key) {
+                workspaces.push(workspace.clone());
+            }
+        }
+        groups.into_iter()
+    }
+
     pub fn workspace(&self) -> &Entity<Workspace> {
         &self.workspaces[self.active_workspace_index]
     }