Show multiple worktree chips in sidebar for threads with mismatched worktrees (#52544)

Eric Holk created

## Summary

When a thread was created in a workspace whose roots span different git
worktrees (e.g. the `olivetti` branch of project_a and the `selectric`
branch of project_b), the sidebar now shows a worktree chip for each
distinct branch name — like `{olivetti} {selectric}` — each with its own
git worktree icon. Same-named worktrees are collapsed into a single
chip. The tooltip on any chip shows the full list of worktree paths.

Previously, only one worktree chip was ever shown (from the first path).

### Implementation

- Introduces `WorktreeInfo` struct replacing the flat
`worktree_name`/`worktree_full_path`/`worktree_highlight_positions`
fields on `ThreadEntry` with `worktrees: Vec<WorktreeInfo>`
- Adds `worktree_info_from_thread_paths` which derives worktree display
info from a thread's own stored `folder_paths` metadata rather than from
the workspace path
- Updates `ThreadItem` in the UI crate to accept
`Vec<ThreadItemWorktree>` and render multiple chips, deduplicating by
name
- Updates fuzzy search to match against all worktree names
- Adds tests for multiple worktree chips and same-name deduplication

Release Notes:

- N/A

Change summary

crates/sidebar/src/project_group_builder.rs |  14 
crates/sidebar/src/sidebar.rs               | 413 +++++++++++++++++-----
crates/ui/src/components/ai/thread_item.rs  | 127 ++++--
3 files changed, 404 insertions(+), 150 deletions(-)

Detailed changes

crates/sidebar/src/project_group_builder.rs 🔗

@@ -166,24 +166,26 @@ impl ProjectGroupBuilder {
             .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`.
+    /// 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 {
-        let worktree_arc: Arc<Path> = Arc::from(worktree_path);
-        if group.covered_paths.contains(&worktree_arc) {
+        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()

crates/sidebar/src/sidebar.rs 🔗

@@ -30,7 +30,8 @@ use std::rc::Rc;
 use theme::ActiveTheme;
 use ui::{
     AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
-    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
+    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip,
+    WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
 use util::path_list::PathList;
@@ -102,6 +103,13 @@ enum ThreadEntryWorkspace {
     Closed(PathList),
 }
 
+#[derive(Clone)]
+struct WorktreeInfo {
+    name: SharedString,
+    full_path: SharedString,
+    highlight_positions: Vec<usize>,
+}
+
 #[derive(Clone)]
 struct ThreadEntry {
     agent: Agent,
@@ -114,9 +122,7 @@ struct ThreadEntry {
     is_background: bool,
     is_title_generating: bool,
     highlight_positions: Vec<usize>,
-    worktree_name: Option<SharedString>,
-    worktree_full_path: Option<SharedString>,
-    worktree_highlight_positions: Vec<usize>,
+    worktrees: Vec<WorktreeInfo>,
     diff_stats: DiffStats,
 }
 
@@ -229,6 +235,33 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
     PathList::new(&workspace.read(cx).root_paths(cx))
 }
 
+/// 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.
+fn worktree_info_from_thread_paths(
+    folder_paths: &PathList,
+    project_groups: &ProjectGroupBuilder,
+) -> Vec<WorktreeInfo> {
+    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
+            }
+        })
+        .collect()
+}
+
 /// The sidebar re-derives its entire entry list from scratch on every
 /// change via `update_entries` → `rebuild_contents`. Avoid adding
 /// incremental or inter-event coordination state — if something can
@@ -693,39 +726,21 @@ impl Sidebar {
                 for workspace in &group.workspaces {
                     let ws_path_list = workspace_path_list(workspace, cx);
 
-                    // Determine if this workspace covers a git worktree (its
-                    // path canonicalizes to the main repo, not itself). If so,
-                    // threads from it get a worktree chip in the sidebar.
-                    let worktree_info: Option<(SharedString, SharedString)> =
-                        ws_path_list.paths().first().and_then(|path| {
-                            let canonical = project_groups.canonicalize_path(path);
-                            if canonical != path.as_path() {
-                                let name =
-                                    linked_worktree_short_name(canonical, path).unwrap_or_default();
-                                let full_path: SharedString = path.display().to_string().into();
-                                Some((name, full_path))
-                            } else {
-                                None
-                            }
-                        });
-
-                    let workspace_threads: Vec<_> = thread_store
-                        .read(cx)
-                        .entries_for_path(&ws_path_list)
-                        .collect();
-                    for thread in workspace_threads {
-                        if !seen_session_ids.insert(thread.session_id.clone()) {
+                    for row in thread_store.read(cx).entries_for_path(&ws_path_list) {
+                        if !seen_session_ids.insert(row.session_id.clone()) {
                             continue;
                         }
-                        let (agent, icon, icon_from_external_svg) = resolve_agent(&thread);
+                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
+                        let worktrees =
+                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
                         threads.push(ThreadEntry {
                             agent,
                             session_info: acp_thread::AgentSessionInfo {
-                                session_id: thread.session_id.clone(),
+                                session_id: row.session_id.clone(),
                                 work_dirs: None,
-                                title: Some(thread.title.clone()),
-                                updated_at: Some(thread.updated_at),
-                                created_at: thread.created_at,
+                                title: Some(row.title.clone()),
+                                updated_at: Some(row.updated_at),
+                                created_at: row.created_at,
                                 meta: None,
                             },
                             icon,
@@ -736,20 +751,15 @@ impl Sidebar {
                             is_background: false,
                             is_title_generating: false,
                             highlight_positions: Vec::new(),
-                            worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
-                            worktree_full_path: worktree_info
-                                .as_ref()
-                                .map(|(_, path)| path.clone()),
-                            worktree_highlight_positions: Vec::new(),
+                            worktrees,
                             diff_stats: DiffStats::default(),
                         });
                     }
                 }
 
-                // Load threads from linked git worktrees that don't have an
-                // open workspace in this group. Only include worktrees that
-                // belong to this group (not shared with another group).
-                let linked_worktree_path_lists = group
+                // 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))
@@ -765,23 +775,14 @@ impl Sidebar {
                             .collect::<Vec<_>>()
                     });
 
-                for worktree_path_list in linked_worktree_path_lists {
+                for worktree_path_list in linked_worktree_queries {
                     for row in thread_store.read(cx).entries_for_path(&worktree_path_list) {
                         if !seen_session_ids.insert(row.session_id.clone()) {
                             continue;
                         }
-                        let worktree_info = row.folder_paths.paths().first().and_then(|path| {
-                            let canonical = project_groups.canonicalize_path(path);
-                            if canonical != path.as_path() {
-                                let name =
-                                    linked_worktree_short_name(canonical, path).unwrap_or_default();
-                                let full_path: SharedString = path.display().to_string().into();
-                                Some((name, full_path))
-                            } else {
-                                None
-                            }
-                        });
                         let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
+                        let worktrees =
+                            worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
                         threads.push(ThreadEntry {
                             agent,
                             session_info: acp_thread::AgentSessionInfo {
@@ -795,14 +796,12 @@ impl Sidebar {
                             icon,
                             icon_from_external_svg,
                             status: AgentThreadStatus::default(),
-                            workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()),
+                            workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
                             is_live: false,
                             is_background: false,
                             is_title_generating: false,
                             highlight_positions: Vec::new(),
-                            worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
-                            worktree_full_path: worktree_info.map(|(_, path)| path),
-                            worktree_highlight_positions: Vec::new(),
+                            worktrees,
                             diff_stats: DiffStats::default(),
                         });
                     }
@@ -882,12 +881,13 @@ impl Sidebar {
                     if let Some(positions) = fuzzy_match_positions(&query, title) {
                         thread.highlight_positions = positions;
                     }
-                    if let Some(worktree_name) = &thread.worktree_name {
-                        if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
-                            thread.worktree_highlight_positions = positions;
+                    let mut worktree_matched = false;
+                    for worktree in &mut thread.worktrees {
+                        if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) {
+                            worktree.highlight_positions = positions;
+                            worktree_matched = true;
                         }
                     }
-                    let worktree_matched = !thread.worktree_highlight_positions.is_empty();
                     if workspace_matched
                         || !thread.highlight_positions.is_empty()
                         || worktree_matched
@@ -2437,14 +2437,17 @@ impl Sidebar {
             .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
                 this.custom_icon_from_external_svg(svg)
             })
-            .when_some(thread.worktree_name.clone(), |this, name| {
-                let this = this.worktree(name);
-                match thread.worktree_full_path.clone() {
-                    Some(path) => this.worktree_full_path(path),
-                    None => this,
-                }
-            })
-            .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
+            .worktrees(
+                thread
+                    .worktrees
+                    .iter()
+                    .map(|wt| ThreadItemWorktreeInfo {
+                        name: wt.name.clone(),
+                        full_path: wt.full_path.clone(),
+                        highlight_positions: wt.highlight_positions.clone(),
+                    })
+                    .collect(),
+            )
             .when_some(timestamp, |this, ts| this.timestamp(ts))
             .highlight_positions(thread.highlight_positions.to_vec())
             .title_generating(thread.is_title_generating)
@@ -3400,11 +3403,19 @@ mod tests {
                             } else {
                                 ""
                             };
-                            let worktree = thread
-                                .worktree_name
-                                .as_ref()
-                                .map(|name| format!(" {{{}}}", name))
-                                .unwrap_or_default();
+                            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
@@ -3777,9 +3788,7 @@ mod tests {
                     is_background: false,
                     is_title_generating: false,
                     highlight_positions: Vec::new(),
-                    worktree_name: None,
-                    worktree_full_path: None,
-                    worktree_highlight_positions: Vec::new(),
+                    worktrees: Vec::new(),
                     diff_stats: DiffStats::default(),
                 }),
                 // Active thread with Running status
@@ -3801,9 +3810,7 @@ mod tests {
                     is_background: false,
                     is_title_generating: false,
                     highlight_positions: Vec::new(),
-                    worktree_name: None,
-                    worktree_full_path: None,
-                    worktree_highlight_positions: Vec::new(),
+                    worktrees: Vec::new(),
                     diff_stats: DiffStats::default(),
                 }),
                 // Active thread with Error status
@@ -3825,9 +3832,7 @@ mod tests {
                     is_background: false,
                     is_title_generating: false,
                     highlight_positions: Vec::new(),
-                    worktree_name: None,
-                    worktree_full_path: None,
-                    worktree_highlight_positions: Vec::new(),
+                    worktrees: Vec::new(),
                     diff_stats: DiffStats::default(),
                 }),
                 // Thread with WaitingForConfirmation status, not active
@@ -3849,9 +3854,7 @@ mod tests {
                     is_background: false,
                     is_title_generating: false,
                     highlight_positions: Vec::new(),
-                    worktree_name: None,
-                    worktree_full_path: None,
-                    worktree_highlight_positions: Vec::new(),
+                    worktrees: Vec::new(),
                     diff_stats: DiffStats::default(),
                 }),
                 // Background thread that completed (should show notification)
@@ -3873,9 +3876,7 @@ mod tests {
                     is_background: true,
                     is_title_generating: false,
                     highlight_positions: Vec::new(),
-                    worktree_name: None,
-                    worktree_full_path: None,
-                    worktree_highlight_positions: Vec::new(),
+                    worktrees: Vec::new(),
                     diff_stats: DiffStats::default(),
                 }),
                 // View More entry
@@ -5817,6 +5818,227 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) {
+        // A thread created in a workspace with roots from different git
+        // worktrees should show a chip for each distinct worktree name.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        // Two main repos.
+        fs.insert_tree(
+            "/project_a",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "olivetti": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/olivetti",
+                        },
+                        "selectric": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/selectric",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/project_b",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "olivetti": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/olivetti",
+                        },
+                        "selectric": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/selectric",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+
+        // Worktree checkouts.
+        for (repo, branch) in &[
+            ("project_a", "olivetti"),
+            ("project_a", "selectric"),
+            ("project_b", "olivetti"),
+            ("project_b", "selectric"),
+        ] {
+            let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}");
+            let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}");
+            fs.insert_tree(
+                &worktree_path,
+                serde_json::json!({
+                    ".git": gitdir,
+                    "src": {},
+                }),
+            )
+            .await;
+        }
+
+        // Register linked worktrees.
+        for repo in &["project_a", "project_b"] {
+            let git_path = format!("/{repo}/.git");
+            fs.with_git_state(std::path::Path::new(&git_path), false, |state| {
+                for branch in &["olivetti", "selectric"] {
+                    state.worktrees.push(git::repository::Worktree {
+                        path: std::path::PathBuf::from(format!(
+                            "/worktrees/{repo}/{branch}/{repo}"
+                        )),
+                        ref_name: Some(format!("refs/heads/{branch}").into()),
+                        sha: "aaa".into(),
+                    });
+                }
+            })
+            .unwrap();
+        }
+
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        // Open a workspace with the worktree checkout paths as roots
+        // (this is the workspace the thread was created in).
+        let project = project::Project::test(
+            fs.clone(),
+            [
+                "/worktrees/project_a/olivetti/project_a".as_ref(),
+                "/worktrees/project_b/selectric/project_b".as_ref(),
+            ],
+            cx,
+        )
+        .await;
+        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Save a thread under the same paths as the workspace roots.
+        let thread_paths = PathList::new(&[
+            std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
+            std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"),
+        ]);
+        save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Should show two distinct worktree chips.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project_a, project_b]",
+                "  Cross Worktree Thread {olivetti}, {selectric}",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) {
+        // When a thread's roots span multiple repos but share the same
+        // worktree name (e.g. both in "olivetti"), only one chip should
+        // appear.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        fs.insert_tree(
+            "/project_a",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "olivetti": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/olivetti",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/project_b",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "olivetti": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/olivetti",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+
+        for repo in &["project_a", "project_b"] {
+            let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}");
+            let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti");
+            fs.insert_tree(
+                &worktree_path,
+                serde_json::json!({
+                    ".git": gitdir,
+                    "src": {},
+                }),
+            )
+            .await;
+
+            let git_path = format!("/{repo}/.git");
+            fs.with_git_state(std::path::Path::new(&git_path), false, |state| {
+                state.worktrees.push(git::repository::Worktree {
+                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
+                    ref_name: Some("refs/heads/olivetti".into()),
+                    sha: "aaa".into(),
+                });
+            })
+            .unwrap();
+        }
+
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project = project::Project::test(
+            fs.clone(),
+            [
+                "/worktrees/project_a/olivetti/project_a".as_ref(),
+                "/worktrees/project_b/olivetti/project_b".as_ref(),
+            ],
+            cx,
+        )
+        .await;
+        project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Thread with roots in both repos' "olivetti" worktrees.
+        let thread_paths = PathList::new(&[
+            std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
+            std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"),
+        ]);
+        save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Both worktree paths have the name "olivetti", so only one chip.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project_a, project_b]",
+                "  Same Branch Thread {olivetti}",
+            ]
+        );
+    }
+
     #[gpui::test]
     async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
         // When a worktree workspace is absorbed under the main repo, a
@@ -6251,7 +6473,7 @@ mod tests {
                             .as_ref()
                             .map(|title| title.as_ref())
                             == Some("WT Thread")
-                            && thread.worktree_name.as_ref().map(|name| name.as_ref())
+                            && thread.worktrees.first().map(|wt| wt.name.as_ref())
                                 == Some("wt-feature-a") =>
                     {
                         saw_expected_thread = true;
@@ -6264,9 +6486,9 @@ mod tests {
                             .map(|title| title.as_ref())
                             .unwrap_or("Untitled");
                         let worktree_name = thread
-                            .worktree_name
-                            .as_ref()
-                            .map(|name| name.as_ref())
+                            .worktrees
+                            .first()
+                            .map(|wt| wt.name.as_ref())
                             .unwrap_or("<none>");
                         panic!(
                             "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`"
@@ -7070,6 +7292,7 @@ mod tests {
         init_test(cx);
         let fs = FakeFs::new(cx.executor());
 
+        // Two independent repos, each with their own git history.
         fs.insert_tree(
             "/project",
             serde_json::json!({
@@ -7102,6 +7325,7 @@ mod tests {
         )
         .await;
 
+        // Register the linked worktree in the main repo.
         fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
             state.worktrees.push(git::repository::Worktree {
                 path: std::path::PathBuf::from("/wt-feature-a"),
@@ -7113,11 +7337,13 @@ mod tests {
 
         cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 
+        // Workspace 1: just /project.
         let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
         project_only
             .update(cx, |p, cx| p.git_scans_complete(cx))
             .await;
 
+        // Workspace 2: /other and /project together (multi-root).
         let multi_root =
             project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await;
         multi_root
@@ -7132,12 +7358,15 @@ mod tests {
         });
         let sidebar = setup_sidebar(&multi_workspace, cx);
 
+        // Save a thread under the linked worktree path.
         let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
         save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
 
         multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
         cx.run_until_parked();
 
+        // The thread should appear only under [project] (the dedicated
+        // group for the /project repo), not under [other, project].
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
             vec![

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

@@ -18,6 +18,13 @@ pub enum AgentThreadStatus {
     Error,
 }
 
+#[derive(Clone)]
+pub struct ThreadItemWorktreeInfo {
+    pub name: SharedString,
+    pub full_path: SharedString,
+    pub highlight_positions: Vec<usize>,
+}
+
 #[derive(IntoElement, RegisterComponent)]
 pub struct ThreadItem {
     id: ElementId,
@@ -37,9 +44,7 @@ pub struct ThreadItem {
     hovered: bool,
     added: Option<usize>,
     removed: Option<usize>,
-    worktree: Option<SharedString>,
-    worktree_full_path: Option<SharedString>,
-    worktree_highlight_positions: Vec<usize>,
+    worktrees: Vec<ThreadItemWorktreeInfo>,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
     action_slot: Option<AnyElement>,
@@ -66,9 +71,7 @@ impl ThreadItem {
             hovered: false,
             added: None,
             removed: None,
-            worktree: None,
-            worktree_full_path: None,
-            worktree_highlight_positions: Vec::new(),
+            worktrees: Vec::new(),
             on_click: None,
             on_hover: Box::new(|_, _, _| {}),
             action_slot: None,
@@ -146,18 +149,8 @@ impl ThreadItem {
         self
     }
 
-    pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
-        self.worktree = Some(worktree.into());
-        self
-    }
-
-    pub fn worktree_full_path(mut self, worktree_full_path: impl Into<SharedString>) -> Self {
-        self.worktree_full_path = Some(worktree_full_path.into());
-        self
-    }
-
-    pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
-        self.worktree_highlight_positions = positions;
+    pub fn worktrees(mut self, worktrees: Vec<ThreadItemWorktreeInfo>) -> Self {
+        self.worktrees = worktrees;
         self
     }
 
@@ -319,7 +312,7 @@ impl RenderOnce for ThreadItem {
         let added_count = self.added.unwrap_or(0);
         let removed_count = self.removed.unwrap_or(0);
 
-        let has_worktree = self.worktree.is_some();
+        let has_worktree = !self.worktrees.is_empty();
         let has_timestamp = !self.timestamp.is_empty();
         let timestamp = self.timestamp;
 
@@ -376,48 +369,67 @@ impl RenderOnce for ThreadItem {
                     }),
             )
             .when(has_worktree || has_diff_stats || has_timestamp, |this| {
-                let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default();
-                let worktree_label = self.worktree.map(|worktree| {
-                    let positions = self.worktree_highlight_positions;
-                    if positions.is_empty() {
-                        Label::new(worktree)
+                // Collect all full paths for the shared tooltip.
+                let worktree_tooltip: SharedString = self
+                    .worktrees
+                    .iter()
+                    .map(|wt| wt.full_path.as_ref())
+                    .collect::<Vec<_>>()
+                    .join("\n")
+                    .into();
+                let worktree_tooltip_title = if self.worktrees.len() > 1 {
+                    "Thread Running in Local Git Worktrees"
+                } else {
+                    "Thread Running in a Local Git Worktree"
+                };
+
+                // Deduplicate chips by name — e.g. two paths both named
+                // "olivetti" produce a single chip. Highlight positions
+                // come from the first occurrence.
+                let mut seen_names: Vec<SharedString> = Vec::new();
+                let mut worktree_chips: Vec<AnyElement> = Vec::new();
+                for wt in self.worktrees {
+                    if seen_names.contains(&wt.name) {
+                        continue;
+                    }
+                    let chip_index = seen_names.len();
+                    seen_names.push(wt.name.clone());
+                    let label = if wt.highlight_positions.is_empty() {
+                        Label::new(wt.name)
                             .size(LabelSize::Small)
                             .color(Color::Muted)
                             .into_any_element()
                     } else {
-                        HighlightedLabel::new(worktree, positions)
+                        HighlightedLabel::new(wt.name, wt.highlight_positions)
                             .size(LabelSize::Small)
                             .color(Color::Muted)
                             .into_any_element()
-                    }
-                });
+                    };
+                    let tooltip_title = worktree_tooltip_title;
+                    let tooltip_meta = worktree_tooltip.clone();
+                    worktree_chips.push(
+                        h_flex()
+                            .id(format!("{}-worktree-{chip_index}", self.id.clone()))
+                            .gap_0p5()
+                            .child(
+                                Icon::new(IconName::GitWorktree)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted),
+                            )
+                            .child(label)
+                            .tooltip(move |_, cx| {
+                                Tooltip::with_meta(tooltip_title, None, tooltip_meta.clone(), cx)
+                            })
+                            .into_any_element(),
+                    );
+                }
 
                 this.child(
                     h_flex()
                         .min_w_0()
                         .gap_1p5()
                         .child(icon_container()) // Icon Spacing
-                        .when_some(worktree_label, |this, label| {
-                            this.child(
-                                h_flex()
-                                    .id(format!("{}-worktree", self.id.clone()))
-                                    .gap_0p5()
-                                    .child(
-                                        Icon::new(IconName::GitWorktree)
-                                            .size(IconSize::XSmall)
-                                            .color(Color::Muted),
-                                    )
-                                    .child(label)
-                                    .tooltip(move |_, cx| {
-                                        Tooltip::with_meta(
-                                            "Thread Running in a Local Git Worktree",
-                                            None,
-                                            worktree_full_path.clone(),
-                                            cx,
-                                        )
-                                    }),
-                            )
-                        })
+                        .children(worktree_chips)
                         .when(has_worktree && (has_diff_stats || has_timestamp), |this| {
                             this.child(dot_separator())
                         })
@@ -526,7 +538,11 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
                             .icon(IconName::AiClaude)
                             .timestamp("2w")
-                            .worktree("link-agent-panel"),
+                            .worktrees(vec![ThreadItemWorktreeInfo {
+                                name: "link-agent-panel".into(),
+                                full_path: "link-agent-panel".into(),
+                                highlight_positions: Vec::new(),
+                            }]),
                     )
                     .into_any_element(),
             ),
@@ -548,7 +564,11 @@ impl Component for ThreadItem {
                     .child(
                         ThreadItem::new("ti-5b", "Full metadata example")
                             .icon(IconName::AiClaude)
-                            .worktree("my-project")
+                            .worktrees(vec![ThreadItemWorktreeInfo {
+                                name: "my-project".into(),
+                                full_path: "my-project".into(),
+                                highlight_positions: Vec::new(),
+                            }])
                             .added(42)
                             .removed(17)
                             .timestamp("3w"),
@@ -623,8 +643,11 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-11", "Search in worktree name")
                             .icon(IconName::AiClaude)
                             .timestamp("3mo")
-                            .worktree("my-project-name")
-                            .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]),
+                            .worktrees(vec![ThreadItemWorktreeInfo {
+                                name: "my-project-name".into(),
+                                full_path: "my-project-name".into(),
+                                highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11],
+                            }]),
                     )
                     .into_any_element(),
             ),