sidebar: Show branch name after worktree name (#53900)

Richard Feldman created

<img width="297" height="505" alt="Screenshot 2026-04-14 at 3 20 23 PM"
src="https://github.com/user-attachments/assets/90366ccd-86db-497e-9c81-af94b4144ebe"
/>


Display the git branch name alongside the worktree name in the sidebar's
thread list (or without the worktree name, if it's a main worktree).

Release Notes:

- Added branch name display next to worktree names in the agent sidebar
thread list.

Change summary

crates/agent_ui/src/thread_metadata_store.rs | 108 +++++++
crates/agent_ui/src/threads_archive_view.rs  |  61 ++++
crates/sidebar/src/sidebar.rs                | 132 ++-------
crates/sidebar/src/sidebar_tests.rs          |  56 ++++
crates/ui/src/components/ai/thread_item.rs   | 294 ++++++++++++++-------
crates/zed/src/visual_test_runner.rs         | 270 ++++++++++++++++++++
6 files changed, 723 insertions(+), 198 deletions(-)

Detailed changes

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -21,10 +21,10 @@ use db::{
 use fs::Fs;
 use futures::{FutureExt, future::Shared};
 use gpui::{AppContext as _, Entity, Global, Subscription, Task};
-use project::AgentId;
 pub use project::WorktreePaths;
+use project::{AgentId, linked_worktree_short_name};
 use remote::{RemoteConnectionOptions, same_remote_connection_identity};
-use ui::{App, Context, SharedString};
+use ui::{App, Context, SharedString, ThreadItemWorktreeInfo, WorktreeKind};
 use util::ResultExt as _;
 use workspace::{PathList, SerializedWorkspaceLocation, WorkspaceDb};
 
@@ -314,6 +314,73 @@ impl ThreadMetadata {
     }
 }
 
+/// Derives worktree display info from a thread's stored path list.
+///
+/// For each path in the thread's `folder_paths`, produces a
+/// [`ThreadItemWorktreeInfo`] with a short display name, full path, and whether
+/// the worktree is the main checkout or a linked git worktree. When
+/// multiple main paths exist and a linked worktree's short name alone
+/// wouldn't identify which main project it belongs to, the main project
+/// name is prefixed for disambiguation (e.g. `project:feature`).
+pub fn worktree_info_from_thread_paths<S: std::hash::BuildHasher>(
+    worktree_paths: &WorktreePaths,
+    branch_names: &std::collections::HashMap<PathBuf, SharedString, S>,
+) -> Vec<ThreadItemWorktreeInfo> {
+    let mut infos: Vec<ThreadItemWorktreeInfo> = Vec::new();
+    let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
+    let mut unique_main_count = HashSet::default();
+
+    for (main_path, folder_path) in worktree_paths.ordered_pairs() {
+        unique_main_count.insert(main_path.clone());
+        let is_linked = main_path != folder_path;
+
+        if is_linked {
+            let short_name = linked_worktree_short_name(main_path, folder_path).unwrap_or_default();
+            let project_name = main_path
+                .file_name()
+                .map(|n| SharedString::from(n.to_string_lossy().to_string()))
+                .unwrap_or_default();
+            linked_short_names.push((short_name.clone(), project_name));
+            infos.push(ThreadItemWorktreeInfo {
+                name: short_name,
+                full_path: SharedString::from(folder_path.display().to_string()),
+                highlight_positions: Vec::new(),
+                kind: WorktreeKind::Linked,
+                branch_name: branch_names.get(folder_path).cloned(),
+            });
+        } else {
+            let Some(name) = folder_path.file_name() else {
+                continue;
+            };
+            infos.push(ThreadItemWorktreeInfo {
+                name: SharedString::from(name.to_string_lossy().to_string()),
+                full_path: SharedString::from(folder_path.display().to_string()),
+                highlight_positions: Vec::new(),
+                kind: WorktreeKind::Main,
+                branch_name: branch_names.get(folder_path).cloned(),
+            });
+        }
+    }
+
+    // When the group has multiple main worktree paths and the thread's
+    // folder paths don't all share the same short name, prefix each
+    // linked worktree chip with its main project name so the user knows
+    // which project it belongs to.
+    let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name);
+
+    if unique_main_count.len() > 1 && !all_same_name {
+        for (info, (_short_name, project_name)) in infos
+            .iter_mut()
+            .filter(|i| i.kind == WorktreeKind::Linked)
+            .zip(linked_short_names.iter())
+        {
+            info.name = SharedString::from(format!("{}:{}", project_name, info.name));
+        }
+    }
+
+    infos
+}
+
 impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
     fn from(meta: &ThreadMetadata) -> Self {
         let session_id = meta
@@ -938,6 +1005,14 @@ impl ThreadMetadataStore {
         })
     }
 
+    pub fn get_all_archived_branch_names(
+        &self,
+        cx: &App,
+    ) -> Task<anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>>> {
+        let db = self.db.clone();
+        cx.background_spawn(async move { db.get_all_archived_branch_names() })
+    }
+
     fn update_archived(&mut self, thread_id: ThreadId, archived: bool, cx: &mut Context<Self>) {
         if let Some(thread) = self.threads.get(&thread_id) {
             self.save_internal(ThreadMetadata {
@@ -1094,10 +1169,10 @@ impl ThreadMetadataStore {
                 )
             } else {
                 let project = thread_ref.project().read(cx);
-                (
-                    project.worktree_paths(cx),
-                    project.remote_connection_options(cx),
-                )
+                let worktree_paths = project.worktree_paths(cx);
+                let remote_connection = project.remote_connection_options(cx);
+
+                (worktree_paths, remote_connection)
             };
 
         // Threads without a folder path (e.g. started in an empty
@@ -1406,6 +1481,27 @@ impl ThreadMetadataDb {
         )?(archived_worktree_id)
         .map(|count| count.unwrap_or(0) > 0)
     }
+
+    pub fn get_all_archived_branch_names(
+        &self,
+    ) -> anyhow::Result<HashMap<ThreadId, HashMap<PathBuf, String>>> {
+        let rows = self.select::<(ThreadId, String, String)>(
+            "SELECT t.thread_id, a.worktree_path, a.branch_name \
+             FROM thread_archived_worktrees t \
+             JOIN archived_git_worktrees a ON a.id = t.archived_worktree_id \
+             WHERE a.branch_name IS NOT NULL \
+             ORDER BY a.id ASC",
+        )?()?;
+
+        let mut result: HashMap<ThreadId, HashMap<PathBuf, String>> = HashMap::default();
+        for (thread_id, worktree_path, branch_name) in rows {
+            result
+                .entry(thread_id)
+                .or_default()
+                .insert(PathBuf::from(worktree_path), branch_name);
+        }
+        Ok(result)
+    }
 }
 
 impl Column for ThreadMetadata {

crates/agent_ui/src/threads_archive_view.rs 🔗

@@ -1,15 +1,19 @@
 use std::collections::HashSet;
+use std::path::PathBuf;
 use std::sync::Arc;
 
 use crate::agent_connection_store::AgentConnectionStore;
 
-use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use crate::thread_metadata_store::{
+    ThreadId, ThreadMetadata, ThreadMetadataStore, worktree_info_from_thread_paths,
+};
 use crate::{Agent, DEFAULT_THREAD_TITLE, RemoveSelectedThread};
 
 use agent::ThreadStore;
 use agent_client_protocol as acp;
 use agent_settings::AgentSettings;
 use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
+use collections::HashMap;
 use editor::Editor;
 use fs::Fs;
 use fuzzy::{StringMatch, StringMatchCandidate};
@@ -133,6 +137,9 @@ pub struct ThreadsArchiveView {
     agent_connection_store: WeakEntity<AgentConnectionStore>,
     agent_server_store: WeakEntity<AgentServerStore>,
     restoring: HashSet<ThreadId>,
+    archived_thread_ids: HashSet<ThreadId>,
+    archived_branch_names: HashMap<ThreadId, HashMap<PathBuf, String>>,
+    _load_branch_names_task: Task<()>,
 }
 
 impl ThreadsArchiveView {
@@ -175,6 +182,7 @@ impl ThreadsArchiveView {
             &ThreadMetadataStore::global(cx),
             |this: &mut Self, _, cx| {
                 this.update_items(cx);
+                this.reload_branch_names_if_threads_changed(cx);
             },
         );
 
@@ -202,9 +210,13 @@ impl ThreadsArchiveView {
             agent_connection_store,
             agent_server_store,
             restoring: HashSet::default(),
+            archived_thread_ids: HashSet::default(),
+            archived_branch_names: HashMap::default(),
+            _load_branch_names_task: Task::ready(()),
         };
 
         this.update_items(cx);
+        this.reload_branch_names_if_threads_changed(cx);
         this
     }
 
@@ -331,6 +343,37 @@ impl ThreadsArchiveView {
         cx.notify();
     }
 
+    fn reload_branch_names_if_threads_changed(&mut self, cx: &mut Context<Self>) {
+        let current_ids: HashSet<ThreadId> = self
+            .items
+            .iter()
+            .filter_map(|item| match item {
+                ArchiveListItem::Entry { thread, .. } => Some(thread.thread_id),
+                _ => None,
+            })
+            .collect();
+
+        if current_ids != self.archived_thread_ids {
+            self.archived_thread_ids = current_ids;
+            self.load_archived_branch_names(cx);
+        }
+    }
+
+    fn load_archived_branch_names(&mut self, cx: &mut Context<Self>) {
+        let task = ThreadMetadataStore::global(cx)
+            .read(cx)
+            .get_all_archived_branch_names(cx);
+        self._load_branch_names_task = cx.spawn(async move |this, cx| {
+            if let Some(branch_names) = task.await.log_err() {
+                this.update(cx, |this, cx| {
+                    this.archived_branch_names = branch_names;
+                    cx.notify();
+                })
+                .log_err();
+            }
+        });
+    }
+
     fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.filter_editor.update(cx, |editor, cx| {
             editor.set_text("", window, cx);
@@ -537,6 +580,20 @@ impl ThreadsArchiveView {
 
                 let is_restoring = self.restoring.contains(&thread.thread_id);
 
+                let branch_names_for_thread: HashMap<PathBuf, SharedString> = self
+                    .archived_branch_names
+                    .get(&thread.thread_id)
+                    .map(|map| {
+                        map.iter()
+                            .map(|(k, v)| (k.clone(), SharedString::from(v.clone())))
+                            .collect()
+                    })
+                    .unwrap_or_default();
+                let worktrees = worktree_info_from_thread_paths(
+                    &thread.worktree_paths,
+                    &branch_names_for_thread,
+                );
+
                 let base = ThreadItem::new(id, thread.display_title())
                     .icon(icon)
                     .when_some(icon_from_external_svg, |this, svg| {
@@ -544,7 +601,7 @@ impl ThreadsArchiveView {
                     })
                     .timestamp(timestamp)
                     .highlight_positions(highlight_positions.clone())
-                    .project_paths(thread.folder_paths().paths_owned())
+                    .worktrees(worktrees)
                     .focused(is_focused)
                     .hovered(is_hovered)
                     .on_hover(cx.listener(move |this, is_hovered, _window, cx| {

crates/sidebar/src/sidebar.rs 🔗

@@ -4,7 +4,9 @@ use acp_thread::ThreadStatus;
 use action_log::DiffStats;
 use agent_client_protocol::{self as acp};
 use agent_settings::AgentSettings;
-use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore, WorktreePaths};
+use agent_ui::thread_metadata_store::{
+    ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths,
+};
 use agent_ui::thread_worktree_archive;
 use agent_ui::threads_archive_view::{
     ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
@@ -23,9 +25,7 @@ use gpui::{
 use menu::{
     Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
 };
-use project::{
-    AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId, linked_worktree_short_name,
-};
+use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, WorktreeId};
 use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
 use remote::{RemoteConnectionOptions, same_remote_connection_identity};
 use ui::utils::platform_title_bar_height;
@@ -36,6 +36,7 @@ use std::collections::{HashMap, HashSet};
 use std::mem;
 use std::path::{Path, PathBuf};
 use std::rc::Rc;
+use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{
     AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel,
@@ -181,14 +182,6 @@ impl ThreadEntryWorkspace {
     }
 }
 
-#[derive(Clone)]
-struct WorktreeInfo {
-    name: SharedString,
-    full_path: SharedString,
-    highlight_positions: Vec<usize>,
-    kind: ui::WorktreeKind,
-}
-
 #[derive(Clone)]
 struct ThreadEntry {
     metadata: ThreadMetadata,
@@ -201,7 +194,7 @@ struct ThreadEntry {
     is_title_generating: bool,
     is_draft: bool,
     highlight_positions: Vec<usize>,
-    worktrees: Vec<WorktreeInfo>,
+    worktrees: Vec<ThreadItemWorktreeInfo>,
     diff_stats: DiffStats,
 }
 
@@ -336,69 +329,6 @@ 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`, produces a
-/// [`WorktreeInfo`] with a short display name, full path, and whether
-/// the worktree is the main checkout or a linked git worktree. When
-/// multiple main paths exist and a linked worktree's short name alone
-/// wouldn't identify which main project it belongs to, the main project
-/// name is prefixed for disambiguation (e.g. `project:feature`).
-///
-fn worktree_info_from_thread_paths(worktree_paths: &WorktreePaths) -> Vec<WorktreeInfo> {
-    let mut infos: Vec<WorktreeInfo> = Vec::new();
-    let mut linked_short_names: Vec<(SharedString, SharedString)> = Vec::new();
-    let mut unique_main_count = HashSet::new();
-
-    for (main_path, folder_path) in worktree_paths.ordered_pairs() {
-        unique_main_count.insert(main_path.clone());
-        let is_linked = main_path != folder_path;
-
-        if is_linked {
-            let short_name = linked_worktree_short_name(main_path, folder_path).unwrap_or_default();
-            let project_name = main_path
-                .file_name()
-                .map(|n| SharedString::from(n.to_string_lossy().to_string()))
-                .unwrap_or_default();
-            linked_short_names.push((short_name.clone(), project_name));
-            infos.push(WorktreeInfo {
-                name: short_name,
-                full_path: SharedString::from(folder_path.display().to_string()),
-                highlight_positions: Vec::new(),
-                kind: ui::WorktreeKind::Linked,
-            });
-        } else {
-            let Some(name) = folder_path.file_name() else {
-                continue;
-            };
-            infos.push(WorktreeInfo {
-                name: SharedString::from(name.to_string_lossy().to_string()),
-                full_path: SharedString::from(folder_path.display().to_string()),
-                highlight_positions: Vec::new(),
-                kind: ui::WorktreeKind::Main,
-            });
-        }
-    }
-
-    // When the group has multiple main worktree paths and the thread's
-    // folder paths don't all share the same short name, prefix each
-    // linked worktree chip with its main project name so the user knows
-    // which project it belongs to.
-    let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name);
-
-    if unique_main_count.len() > 1 && !all_same_name {
-        for (info, (_short_name, project_name)) in infos
-            .iter_mut()
-            .filter(|i| i.kind == ui::WorktreeKind::Linked)
-            .zip(linked_short_names.iter())
-        {
-            info.name = SharedString::from(format!("{}:{}", project_name, info.name));
-        }
-    }
-
-    infos
-}
-
 /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
 /// an SSH connection. Suitable for passing to
 /// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote`
@@ -636,7 +566,8 @@ impl Sidebar {
                     event,
                     project::git_store::GitStoreEvent::RepositoryUpdated(
                         _,
-                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
+                        project::git_store::RepositoryEvent::GitWorktreeListChanged
+                            | project::git_store::RepositoryEvent::HeadChanged,
                         _,
                     )
                 ) {
@@ -1080,6 +1011,28 @@ impl Sidebar {
         let path_detail_map: HashMap<PathBuf, usize> =
             all_paths.into_iter().zip(path_details).collect();
 
+        let mut branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
+        for ws in &workspaces {
+            let project = ws.read(cx).project().read(cx);
+            for repo in project.repositories(cx).values() {
+                let snapshot = repo.read(cx).snapshot();
+                if let Some(branch) = &snapshot.branch {
+                    branch_by_path.insert(
+                        snapshot.work_directory_abs_path.to_path_buf(),
+                        SharedString::from(Arc::<str>::from(branch.name())),
+                    );
+                }
+                for linked_wt in snapshot.linked_worktrees() {
+                    if let Some(branch) = linked_wt.branch_name() {
+                        branch_by_path.insert(
+                            linked_wt.path.clone(),
+                            SharedString::from(Arc::<str>::from(branch)),
+                        );
+                    }
+                }
+            }
+        }
+
         for group in &groups {
             let group_key = &group.key;
             let group_workspaces = &group.workspaces;
@@ -1136,7 +1089,8 @@ impl Sidebar {
                 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.worktree_paths);
+                        let worktrees =
+                            worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path);
                         let is_draft = row.is_draft();
                         ThreadEntry {
                             metadata: row,
@@ -3546,11 +3500,10 @@ impl Sidebar {
                         worktrees: thread
                             .worktrees
                             .iter()
-                            .map(|wt| ThreadItemWorktreeInfo {
-                                name: wt.name.clone(),
-                                full_path: wt.full_path.clone(),
-                                highlight_positions: Vec::new(),
-                                kind: wt.kind,
+                            .cloned()
+                            .map(|mut wt| {
+                                wt.highlight_positions = Vec::new();
+                                wt
                             })
                             .collect(),
                         diff_stats: thread.diff_stats,
@@ -3815,18 +3768,7 @@ impl Sidebar {
             .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
                 this.custom_icon_from_external_svg(svg)
             })
-            .worktrees(
-                thread
-                    .worktrees
-                    .iter()
-                    .map(|wt| ThreadItemWorktreeInfo {
-                        name: wt.name.clone(),
-                        full_path: wt.full_path.clone(),
-                        highlight_positions: wt.highlight_positions.clone(),
-                        kind: wt.kind,
-                    })
-                    .collect(),
-            )
+            .worktrees(thread.worktrees.clone())
             .timestamp(timestamp)
             .highlight_positions(thread.highlight_positions.to_vec())
             .title_generating(thread.is_title_generating)

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -356,7 +356,7 @@ fn request_test_tool_authorization(
     cx.run_until_parked();
 }
 
-fn format_linked_worktree_chips(worktrees: &[WorktreeInfo]) -> String {
+fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String {
     let mut seen = Vec::new();
     let mut chips = Vec::new();
     for wt in worktrees {
@@ -10888,3 +10888,57 @@ async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &m
         "feature-b file should have been closed"
     );
 }
+
+#[test]
+fn test_worktree_info_branch_names_for_main_worktrees() {
+    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
+    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
+
+    let branch_by_path: HashMap<PathBuf, SharedString> =
+        [(PathBuf::from("/projects/myapp"), "feature-x".into())]
+            .into_iter()
+            .collect();
+
+    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
+    assert_eq!(infos.len(), 1);
+    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
+    assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
+    assert_eq!(infos[0].name, SharedString::from("myapp"));
+}
+
+#[test]
+fn test_worktree_info_branch_names_for_linked_worktrees() {
+    let main_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
+    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp-feature")]);
+    let worktree_paths =
+        WorktreePaths::from_path_lists(main_paths, folder_paths).expect("same length");
+
+    let branch_by_path: HashMap<PathBuf, SharedString> = [(
+        PathBuf::from("/projects/myapp-feature"),
+        "feature-branch".into(),
+    )]
+    .into_iter()
+    .collect();
+
+    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
+    assert_eq!(infos.len(), 1);
+    assert_eq!(infos[0].kind, ui::WorktreeKind::Linked);
+    assert_eq!(
+        infos[0].branch_name,
+        Some(SharedString::from("feature-branch"))
+    );
+}
+
+#[test]
+fn test_worktree_info_missing_branch_returns_none() {
+    let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]);
+    let worktree_paths = WorktreePaths::from_folder_paths(&folder_paths);
+
+    let branch_by_path: HashMap<PathBuf, SharedString> = HashMap::new();
+
+    let infos = worktree_info_from_thread_paths(&worktree_paths, &branch_by_path);
+    assert_eq!(infos.len(), 1);
+    assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
+    assert_eq!(infos[0].branch_name, None);
+    assert_eq!(infos[0].name, SharedString::from("myapp"));
+}

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

@@ -29,6 +29,7 @@ pub struct ThreadItemWorktreeInfo {
     pub full_path: SharedString,
     pub highlight_positions: Vec<usize>,
     pub kind: WorktreeKind,
+    pub branch_name: Option<SharedString>,
 }
 
 #[derive(IntoElement, RegisterComponent)]
@@ -374,13 +375,119 @@ 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
-            .iter()
-            .any(|wt| wt.kind == WorktreeKind::Linked);
         let has_timestamp = !self.timestamp.is_empty();
         let timestamp = self.timestamp;
 
+        let visible_worktree_count = self
+            .worktrees
+            .iter()
+            .filter(|wt| !(wt.kind == WorktreeKind::Main && wt.branch_name.is_none()))
+            .count();
+
+        let worktree_tooltip_title = match (self.is_remote, visible_worktree_count > 1) {
+            (true, true) => "Thread Running in Remote Git Worktrees",
+            (true, false) => "Thread Running in a Remote Git Worktree",
+            (false, true) => "Thread Running in Local Git Worktrees",
+            (false, false) => "Thread Running in a Local Git Worktree",
+        };
+
+        let mut worktree_labels: Vec<AnyElement> = Vec::new();
+
+        let slash_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.4));
+
+        for wt in self.worktrees {
+            match (wt.kind, wt.branch_name) {
+                (WorktreeKind::Main, None) => continue,
+                (WorktreeKind::Main, Some(branch)) => {
+                    let chip_index = worktree_labels.len();
+                    let tooltip_title = worktree_tooltip_title;
+                    let full_path = wt.full_path.clone();
+
+                    worktree_labels.push(
+                        h_flex()
+                            .id(format!("{}-worktree-{chip_index}", self.id.clone()))
+                            .min_w_0()
+                            .when(visible_worktree_count > 1, |this| {
+                                this.child(
+                                    Label::new(wt.name)
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted)
+                                        .truncate(),
+                                )
+                                .child(
+                                    Label::new("/")
+                                        .size(LabelSize::Small)
+                                        .color(slash_color)
+                                        .flex_shrink_0(),
+                                )
+                            })
+                            .child(
+                                Label::new(branch)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .truncate(),
+                            )
+                            .tooltip(move |_, cx| {
+                                Tooltip::with_meta(tooltip_title, None, full_path.clone(), cx)
+                            })
+                            .into_any_element(),
+                    );
+                }
+                (WorktreeKind::Linked, branch) => {
+                    let chip_index = worktree_labels.len();
+                    let tooltip_title = worktree_tooltip_title;
+                    let full_path = wt.full_path.clone();
+
+                    let label = if wt.highlight_positions.is_empty() {
+                        Label::new(wt.name)
+                            .size(LabelSize::Small)
+                            .color(Color::Muted)
+                            .truncate()
+                            .into_any_element()
+                    } else {
+                        HighlightedLabel::new(wt.name, wt.highlight_positions)
+                            .size(LabelSize::Small)
+                            .color(Color::Muted)
+                            .truncate()
+                            .into_any_element()
+                    };
+
+                    worktree_labels.push(
+                        h_flex()
+                            .id(format!("{}-worktree-{chip_index}", self.id.clone()))
+                            .min_w_0()
+                            .gap_0p5()
+                            .child(
+                                Icon::new(IconName::GitWorktree)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted),
+                            )
+                            .child(label)
+                            .when_some(branch, |this, branch| {
+                                this.child(
+                                    Label::new("/")
+                                        .size(LabelSize::Small)
+                                        .color(slash_color)
+                                        .flex_shrink_0(),
+                                )
+                                .child(
+                                    Label::new(branch)
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted)
+                                        .truncate(),
+                                )
+                            })
+                            .tooltip(move |_, cx| {
+                                Tooltip::with_meta(tooltip_title, None, full_path.clone(), cx)
+                            })
+                            .into_any_element(),
+                    );
+                }
+            }
+        }
+
+        let has_worktree = !worktree_labels.is_empty();
+
         v_flex()
             .id(self.id.clone())
             .cursor_pointer()
@@ -441,102 +548,41 @@ impl RenderOnce for ThreadItem {
                     || has_diff_stats
                     || has_timestamp,
                 |this| {
-                    // 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 = match (self.is_remote, self.worktrees.len() > 1) {
-                        (true, true) => "Thread Running in Remote Git Worktrees",
-                        (true, false) => "Thread Running in a Remote Git Worktree",
-                        (false, true) => "Thread Running in Local Git Worktrees",
-                        (false, false) => "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_labels: Vec<AnyElement> = Vec::new();
-
-                    for wt in self.worktrees {
-                        if seen_names.contains(&wt.name) {
-                            continue;
-                        }
-
-                        if wt.kind == WorktreeKind::Main {
-                            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(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_labels.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(self.project_name, |this, name| {
-                                this.child(
-                                    Label::new(name).size(LabelSize::Small).color(Color::Muted),
-                                )
-                            })
-                            .when(
-                                has_project_name && (has_project_paths || has_worktree),
-                                |this| this.child(dot_separator()),
+                            .child(
+                                h_flex()
+                                    .min_w_0()
+                                    .flex_shrink()
+                                    .overflow_hidden()
+                                    .gap_1p5()
+                                    .when_some(self.project_name, |this, name| {
+                                        this.child(
+                                            Label::new(name)
+                                                .size(LabelSize::Small)
+                                                .color(Color::Muted),
+                                        )
+                                    })
+                                    .when(
+                                        has_project_name && (has_project_paths || has_worktree),
+                                        |this| this.child(dot_separator()),
+                                    )
+                                    .when_some(project_paths, |this, paths| {
+                                        this.child(
+                                            Label::new(paths)
+                                                .size(LabelSize::Small)
+                                                .color(Color::Muted)
+                                                .into_any_element(),
+                                        )
+                                    })
+                                    .when(has_project_paths && has_worktree, |this| {
+                                        this.child(dot_separator())
+                                    })
+                                    .children(worktree_labels),
                             )
-                            .when_some(project_paths, |this, paths| {
-                                this.child(
-                                    Label::new(paths)
-                                        .size(LabelSize::Small)
-                                        .color(Color::Muted)
-                                        .into_any_element(),
-                                )
-                            })
-                            .when(has_project_paths && has_worktree, |this| {
-                                this.child(dot_separator())
-                            })
-                            .children(worktree_labels)
                             .when(
                                 (has_project_name || has_project_paths || has_worktree)
                                     && (has_diff_stats || has_timestamp),
@@ -648,6 +694,7 @@ impl Component for ThreadItem {
                                 full_path: "link-agent-panel".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
+                                branch_name: None,
                             }]),
                     )
                     .into_any_element(),
@@ -675,6 +722,7 @@ impl Component for ThreadItem {
                                 full_path: "my-project".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
+                                branch_name: None,
                             }])
                             .added(42)
                             .removed(17)
@@ -682,6 +730,63 @@ impl Component for ThreadItem {
                     )
                     .into_any_element(),
             ),
+            single_example(
+                "Worktree + Branch + Changes + Timestamp",
+                container()
+                    .child(
+                        ThreadItem::new("ti-5c", "Full metadata with branch")
+                            .icon(IconName::AiClaude)
+                            .worktrees(vec![ThreadItemWorktreeInfo {
+                                name: "my-project".into(),
+                                full_path: "/worktrees/my-project/zed".into(),
+                                highlight_positions: Vec::new(),
+                                kind: WorktreeKind::Linked,
+                                branch_name: Some("feature-branch".into()),
+                            }])
+                            .added(42)
+                            .removed(17)
+                            .timestamp("3w"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Long Branch + Changes (truncation)",
+                container()
+                    .child(
+                        ThreadItem::new("ti-5d", "Metadata overflow with long branch name")
+                            .icon(IconName::AiClaude)
+                            .worktrees(vec![ThreadItemWorktreeInfo {
+                                name: "my-project".into(),
+                                full_path: "/worktrees/my-project/zed".into(),
+                                highlight_positions: Vec::new(),
+                                kind: WorktreeKind::Linked,
+                                branch_name: Some("fix-very-long-branch-name-here".into()),
+                            }])
+                            .added(108)
+                            .removed(53)
+                            .timestamp("2d"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Main Branch + Changes + Timestamp",
+                container()
+                    .child(
+                        ThreadItem::new("ti-5e", "Main worktree branch with diff stats")
+                            .icon(IconName::ZedAgent)
+                            .worktrees(vec![ThreadItemWorktreeInfo {
+                                name: "zed".into(),
+                                full_path: "/projects/zed".into(),
+                                highlight_positions: Vec::new(),
+                                kind: WorktreeKind::Main,
+                                branch_name: Some("sidebar-show-branch-name".into()),
+                            }])
+                            .added(23)
+                            .removed(8)
+                            .timestamp("5m"),
+                    )
+                    .into_any_element(),
+            ),
             single_example(
                 "Selected Item",
                 container()
@@ -755,6 +860,7 @@ impl Component for ThreadItem {
                                 full_path: "my-project-name".into(),
                                 highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11],
                                 kind: WorktreeKind::Linked,
+                                branch_name: None,
                             }]),
                     )
                     .into_any_element(),

crates/zed/src/visual_test_runner.rs 🔗

@@ -446,6 +446,23 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
         }
     }
 
+    // Run Test: ThreadItem branch names visual test
+    println!("\n--- Test: thread_item_branch_names ---");
+    match run_thread_item_branch_name_visual_tests(app_state.clone(), &mut cx, update_baseline) {
+        Ok(TestResult::Passed) => {
+            println!("✓ thread_item_branch_names: PASSED");
+            passed += 1;
+        }
+        Ok(TestResult::BaselineUpdated(_)) => {
+            println!("✓ thread_item_branch_names: Baseline updated");
+            updated += 1;
+        }
+        Err(e) => {
+            eprintln!("✗ thread_item_branch_names: FAILED - {}", e);
+            failed += 1;
+        }
+    }
+
     // Run Test 3: Multi-workspace sidebar visual tests
     println!("\n--- Test 3: multi_workspace_sidebar ---");
     match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) {
@@ -2849,6 +2866,259 @@ impl gpui::Render for ErrorWrappingTestView {
     }
 }
 
+#[cfg(target_os = "macos")]
+struct ThreadItemBranchNameTestView;
+
+#[cfg(target_os = "macos")]
+impl gpui::Render for ThreadItemBranchNameTestView {
+    fn render(
+        &mut self,
+        _window: &mut gpui::Window,
+        cx: &mut gpui::Context<Self>,
+    ) -> impl gpui::IntoElement {
+        use ui::{
+            IconName, Label, LabelSize, ThreadItem, ThreadItemWorktreeInfo, WorktreeKind,
+            prelude::*,
+        };
+
+        let section_label = |text: &str| {
+            Label::new(text.to_string())
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+        };
+
+        let container = || {
+            v_flex()
+                .w_80()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().panel_background)
+        };
+
+        v_flex()
+            .size_full()
+            .bg(cx.theme().colors().background)
+            .p_4()
+            .gap_3()
+            .child(
+                Label::new("ThreadItem Branch Names")
+                    .size(LabelSize::Large)
+                    .color(Color::Default),
+            )
+            .child(section_label(
+                "Linked worktree with branch (worktree / branch)",
+            ))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-linked-branch", "Fix scrolling behavior")
+                        .icon(IconName::AiClaude)
+                        .timestamp("5m")
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "jade-glen".into(),
+                            full_path: "/worktrees/jade-glen/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Linked,
+                            branch_name: Some("fix-scrolling".into()),
+                        }]),
+                ),
+            )
+            .child(section_label(
+                "Linked worktree without branch (detached HEAD)",
+            ))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-linked-no-branch", "Review worktree cleanup")
+                        .icon(IconName::AiClaude)
+                        .timestamp("1h")
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "focal-arrow".into(),
+                            full_path: "/worktrees/focal-arrow/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Linked,
+                            branch_name: None,
+                        }]),
+                ),
+            )
+            .child(section_label(
+                "Main worktree with branch (branch only, no icon)",
+            ))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-main-branch", "Request for Long Classic Poem")
+                        .icon(IconName::ZedAgent)
+                        .timestamp("2d")
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "zed".into(),
+                            full_path: "/projects/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Main,
+                            branch_name: Some("main".into()),
+                        }]),
+                ),
+            )
+            .child(section_label(
+                "Main worktree without branch (nothing shown)",
+            ))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-main-no-branch", "Simple greeting thread")
+                        .icon(IconName::ZedAgent)
+                        .timestamp("3d")
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "zed".into(),
+                            full_path: "/projects/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Main,
+                            branch_name: None,
+                        }]),
+                ),
+            )
+            .child(section_label("Linked worktree where name matches branch"))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-same-name", "Implement feature")
+                        .icon(IconName::AiClaude)
+                        .timestamp("6d")
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "stoic-reed".into(),
+                            full_path: "/worktrees/stoic-reed/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Linked,
+                            branch_name: Some("stoic-reed".into()),
+                        }]),
+                ),
+            )
+            .child(section_label(
+                "Manually opened linked worktree (main_path resolves to original repo)",
+            ))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-manual-linked", "Robust Git Worktree Rollback")
+                        .icon(IconName::ZedAgent)
+                        .timestamp("40m")
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "focal-arrow".into(),
+                            full_path: "/worktrees/focal-arrow/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Linked,
+                            branch_name: Some("persist-worktree-3-wiring".into()),
+                        }]),
+                ),
+            )
+            .child(section_label(
+                "Linked worktree + branch + diff stats + timestamp",
+            ))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-linked-full", "Full metadata with diff stats")
+                        .icon(IconName::AiClaude)
+                        .timestamp("3w")
+                        .added(42)
+                        .removed(17)
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "jade-glen".into(),
+                            full_path: "/worktrees/jade-glen/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Linked,
+                            branch_name: Some("feature-branch".into()),
+                        }]),
+                ),
+            )
+            .child(section_label("Long branch name truncation with diff stats"))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-long-branch", "Overflow test with very long branch")
+                        .icon(IconName::AiClaude)
+                        .timestamp("2d")
+                        .added(108)
+                        .removed(53)
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "my-project".into(),
+                            full_path: "/worktrees/my-project/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Linked,
+                            branch_name: Some(
+                                "fix-very-long-branch-name-that-should-truncate".into(),
+                            ),
+                        }]),
+                ),
+            )
+            .child(section_label("Main branch + diff stats + timestamp"))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-main-full", "Main worktree with everything")
+                        .icon(IconName::ZedAgent)
+                        .timestamp("5m")
+                        .added(23)
+                        .removed(8)
+                        .worktrees(vec![ThreadItemWorktreeInfo {
+                            name: "zed".into(),
+                            full_path: "/projects/zed".into(),
+                            highlight_positions: Vec::new(),
+                            kind: WorktreeKind::Main,
+                            branch_name: Some("sidebar-show-branch-name".into()),
+                        }]),
+                ),
+            )
+    }
+}
+
+#[cfg(target_os = "macos")]
+fn run_thread_item_branch_name_visual_tests(
+    _app_state: Arc<AppState>,
+    cx: &mut VisualTestAppContext,
+    update_baseline: bool,
+) -> Result<TestResult> {
+    let window_size = size(px(400.0), px(1150.0));
+    let bounds = Bounds {
+        origin: point(px(0.0), px(0.0)),
+        size: window_size,
+    };
+
+    let window = cx
+        .update(|cx| {
+            cx.open_window(
+                WindowOptions {
+                    window_bounds: Some(WindowBounds::Windowed(bounds)),
+                    focus: false,
+                    show: false,
+                    ..Default::default()
+                },
+                |_window, cx| cx.new(|_| ThreadItemBranchNameTestView),
+            )
+        })
+        .context("Failed to open thread item branch name test window")?;
+
+    cx.run_until_parked();
+
+    cx.update_window(window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+
+    cx.run_until_parked();
+
+    let test_result = run_visual_test(
+        "thread_item_branch_names",
+        window.into(),
+        cx,
+        update_baseline,
+    )?;
+
+    cx.update_window(window.into(), |_, window, _cx| {
+        window.remove_window();
+    })
+    .log_err();
+
+    cx.run_until_parked();
+
+    for _ in 0..15 {
+        cx.advance_clock(Duration::from_millis(100));
+        cx.run_until_parked();
+    }
+
+    Ok(test_result)
+}
+
 #[cfg(target_os = "macos")]
 struct ThreadItemIconDecorationsTestView;