sidebar: Fixes around multi-root projects (#53276)

Eric Holk created

When a project mixes main repo worktrees (e.g. `extensions`) with linked
git worktrees (e.g. `zed4`, a linked worktree of `zed`), two things were
broken:

1. **Threads appeared under the wrong sidebar heading.** A thread
created in a workspace with both `extensions` and `zed4` would show
under the "zed" group instead of the "extensions, zed" group. This
happened because `main_worktree_paths` in `ThreadMetadata` was only
populated with paths from linked worktrees, omitting regular repos
entirely. The fix uses `project_group_key()` to normalize all visible
worktrees to their main repo paths — the same normalization the sidebar
uses for group headers.

2. **The worktree chip tooltip only showed linked worktree paths**,
missing main repo paths like `extensions`. This happened because
`worktree_info_from_thread_paths` filtered out main worktree paths
entirely. The fix introduces a `WorktreeKind` enum (`Main` / `Linked`)
on `ThreadItemWorktreeInfo`, so all worktrees are included in the data
model. Chips still only render for `Linked` worktrees (main worktrees
are redundant with the group header), but the tooltip now shows all
paths.

Release Notes:

- N/A

Change summary

crates/agent_ui/src/thread_metadata_store.rs | 19 ++----
crates/sidebar/src/sidebar.rs                | 49 ++++++++++------
crates/sidebar/src/sidebar_tests.rs          | 66 +++++++--------------
crates/ui/src/components/ai/thread_item.rs   | 20 ++++++
4 files changed, 79 insertions(+), 75 deletions(-)

Detailed changes

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -567,19 +567,12 @@ impl ThreadMetadataStore {
                     PathList::new(&paths)
                 };
 
-                let main_worktree_paths = {
-                    let project = thread_ref.project().read(cx);
-                    let mut main_paths: Vec<Arc<Path>> = Vec::new();
-                    for repo in project.repositories(cx).values() {
-                        let snapshot = repo.read(cx).snapshot();
-                        if snapshot.is_linked_worktree() {
-                            main_paths.push(snapshot.original_repo_abs_path.clone());
-                        }
-                    }
-                    main_paths.sort();
-                    main_paths.dedup();
-                    PathList::new(&main_paths)
-                };
+                let main_worktree_paths = thread_ref
+                    .project()
+                    .read(cx)
+                    .project_group_key(cx)
+                    .path_list()
+                    .clone();
 
                 // Threads without a folder path (e.g. started in an empty
                 // window) are archived by default so they don't get lost,

crates/sidebar/src/sidebar.rs 🔗

@@ -163,6 +163,7 @@ struct WorktreeInfo {
     name: SharedString,
     full_path: SharedString,
     highlight_positions: Vec<usize>,
+    kind: ui::WorktreeKind,
 }
 
 #[derive(Clone)]
@@ -307,23 +308,25 @@ 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 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.
+/// For each path in the thread's `folder_paths`, produces a
+/// [`WorktreeInfo`] with a short display name, full path, and whether
+/// the worktree is the main checkout or a linked git worktree.
 fn worktree_info_from_thread_paths(
     folder_paths: &PathList,
     group_key: &project::ProjectGroupKey,
-) -> Vec<WorktreeInfo> {
+) -> impl Iterator<Item = WorktreeInfo> {
     let main_paths = group_key.path_list().paths();
-    folder_paths
-        .paths()
-        .iter()
-        .filter_map(|path| {
-            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.
+    folder_paths.paths().iter().filter_map(|path| {
+        let is_main = main_paths.iter().any(|mp| mp.as_path() == path.as_path());
+        if is_main {
+            let name = path.file_name()?.to_string_lossy().to_string();
+            Some(WorktreeInfo {
+                name: SharedString::from(name),
+                full_path: SharedString::from(path.display().to_string()),
+                highlight_positions: Vec::new(),
+                kind: ui::WorktreeKind::Main,
+            })
+        } else {
             let main_path = main_paths
                 .iter()
                 .find(|mp| mp.file_name() == path.file_name())
@@ -332,9 +335,10 @@ fn worktree_info_from_thread_paths(
                 name: linked_worktree_short_name(main_path, path).unwrap_or_default(),
                 full_path: SharedString::from(path.display().to_string()),
                 highlight_positions: Vec::new(),
+                kind: ui::WorktreeKind::Linked,
             })
-        })
-        .collect()
+        }
+    })
 }
 
 /// The sidebar re-derives its entire entry list from scratch on every
@@ -851,7 +855,8 @@ impl Sidebar {
                                          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);
+                    let worktrees: Vec<WorktreeInfo> =
+                        worktree_info_from_thread_paths(&row.folder_paths, &group_key).collect();
                     ThreadEntry {
                         metadata: row,
                         icon,
@@ -1059,7 +1064,9 @@ impl Sidebar {
                     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 });
+                        entries.push(ListEntry::DraftThread {
+                            worktrees: worktrees.collect(),
+                        });
                     }
                 }
 
@@ -1073,7 +1080,8 @@ impl Sidebar {
                     && 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();
+                            worktree_info_from_thread_paths(&ws_path_list, &group_key)
+                                .any(|wt| wt.kind == ui::WorktreeKind::Linked);
                         if !has_linked_worktrees {
                             return false;
                         }
@@ -1102,6 +1110,7 @@ impl Sidebar {
                                     &workspace_path_list(ws, cx),
                                     &group_key,
                                 )
+                                .collect()
                             })
                             .unwrap_or_default()
                     } else {
@@ -2545,6 +2554,7 @@ impl Sidebar {
                                 name: wt.name.clone(),
                                 full_path: wt.full_path.clone(),
                                 highlight_positions: Vec::new(),
+                                kind: wt.kind,
                             })
                             .collect(),
                         diff_stats: thread.diff_stats,
@@ -2817,6 +2827,7 @@ impl Sidebar {
                         name: wt.name.clone(),
                         full_path: wt.full_path.clone(),
                         highlight_positions: wt.highlight_positions.clone(),
+                        kind: wt.kind,
                     })
                     .collect(),
             )
@@ -3095,6 +3106,7 @@ impl Sidebar {
                         name: wt.name.clone(),
                         full_path: wt.full_path.clone(),
                         highlight_positions: wt.highlight_positions.clone(),
+                        kind: wt.kind,
                     })
                     .collect(),
             )
@@ -3132,6 +3144,7 @@ impl Sidebar {
                         name: wt.name.clone(),
                         full_path: wt.full_path.clone(),
                         highlight_positions: wt.highlight_positions.clone(),
+                        kind: wt.kind,
                     })
                     .collect(),
             )

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -191,6 +191,25 @@ fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
     cx.run_until_parked();
 }
 
+fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
+    let mut seen = Vec::new();
+    let mut chips = Vec::new();
+    for wt in worktrees {
+        if wt.kind == ui::WorktreeKind::Main {
+            continue;
+        }
+        if !seen.contains(&wt.name) {
+            seen.push(wt.name.clone());
+            chips.push(format!("{{{}}}", wt.name));
+        }
+    }
+    if chips.is_empty() {
+        String::new()
+    } else {
+        format!(" {}", chips.join(", "))
+    }
+}
+
 fn visible_entries_as_strings(
     sidebar: &Entity<Sidebar>,
     cx: &mut gpui::VisualTestContext,
@@ -238,23 +257,8 @@ fn visible_entries_as_strings(
                         } else {
                             ""
                         };
-                        let worktree = if thread.worktrees.is_empty() {
-                            String::new()
-                        } else {
-                            let mut seen = Vec::new();
-                            let mut chips = Vec::new();
-                            for wt in &thread.worktrees {
-                                if !seen.contains(&wt.name) {
-                                    seen.push(wt.name.clone());
-                                    chips.push(format!("{{{}}}", wt.name));
-                                }
-                            }
-                            format!(" {}", chips.join(", "))
-                        };
-                        format!(
-                            "  {}{}{}{}{}{}",
-                            title, worktree, active, status_str, notified, selected
-                        )
+                        let worktree = format_linked_worktree_chips(&thread.worktrees);
+                        format!("  {title}{worktree}{active}{status_str}{notified}{selected}")
                     }
                     ListEntry::ViewMore {
                         is_fully_expanded, ..
@@ -266,35 +270,11 @@ fn visible_entries_as_strings(
                         }
                     }
                     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(", "))
-                        };
+                        let worktree = format_linked_worktree_chips(worktrees);
                         format!("  [~ Draft{}]{}", worktree, selected)
                     }
                     ListEntry::NewThread { 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(", "))
-                        };
+                        let worktree = format_linked_worktree_chips(worktrees);
                         format!("  [+ New Thread{}]{}", worktree, selected)
                     }
                 }

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -16,11 +16,19 @@ pub enum AgentThreadStatus {
     Error,
 }
 
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum WorktreeKind {
+    #[default]
+    Main,
+    Linked,
+}
+
 #[derive(Clone)]
 pub struct ThreadItemWorktreeInfo {
     pub name: SharedString,
     pub full_path: SharedString,
     pub highlight_positions: Vec<usize>,
+    pub kind: WorktreeKind,
 }
 
 #[derive(IntoElement, RegisterComponent)]
@@ -359,7 +367,10 @@ impl RenderOnce for ThreadItem {
 
         let has_project_name = self.project_name.is_some();
         let has_project_paths = project_paths.is_some();
-        let has_worktree = !self.worktrees.is_empty();
+        let has_worktree = self
+            .worktrees
+            .iter()
+            .any(|wt| wt.kind == WorktreeKind::Linked);
         let has_timestamp = !self.timestamp.is_empty();
         let timestamp = self.timestamp;
 
@@ -449,6 +460,10 @@ impl RenderOnce for ThreadItem {
                             continue;
                         }
 
+                        if wt.kind == WorktreeKind::Main {
+                            continue;
+                        }
+
                         let chip_index = seen_names.len();
                         seen_names.push(wt.name.clone());
 
@@ -624,6 +639,7 @@ impl Component for ThreadItem {
                                 name: "link-agent-panel".into(),
                                 full_path: "link-agent-panel".into(),
                                 highlight_positions: Vec::new(),
+                                kind: WorktreeKind::Linked,
                             }]),
                     )
                     .into_any_element(),
@@ -650,6 +666,7 @@ impl Component for ThreadItem {
                                 name: "my-project".into(),
                                 full_path: "my-project".into(),
                                 highlight_positions: Vec::new(),
+                                kind: WorktreeKind::Linked,
                             }])
                             .added(42)
                             .removed(17)
@@ -729,6 +746,7 @@ impl Component for ThreadItem {
                                 name: "my-project-name".into(),
                                 full_path: "my-project-name".into(),
                                 highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11],
+                                kind: WorktreeKind::Linked,
                             }]),
                     )
                     .into_any_element(),