agent_panel: Refine git pickers design in the empty thread view (#53324)

Danilo Leal , cameron , and Anthony Eid created

Follow-up to https://github.com/zed-industries/zed/pull/52979 with some
design refinements.

Release Notes:

- N/A

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>

Change summary

crates/agent_ui/src/agent_panel.rs            | 192 ++++++++++---
crates/agent_ui/src/config_options.rs         |  17 
crates/agent_ui/src/mode_selector.rs          |  21 
crates/agent_ui/src/model_selector.rs         |  19 
crates/agent_ui/src/profile_selector.rs       |  16 
crates/agent_ui/src/thread_branch_picker.rs   | 255 +++++++++++-------
crates/agent_ui/src/thread_worktree_picker.rs | 280 +++++++++++++++-----
crates/agent_ui/src/ui.rs                     |  13 
8 files changed, 552 insertions(+), 261 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -72,7 +72,7 @@ use terminal::terminal_settings::TerminalSettings;
 use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
 use theme_settings::ThemeSettings;
 use ui::{
-    Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
+    Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
     PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
 };
 use util::{ResultExt as _, debug_panic};
@@ -615,37 +615,130 @@ enum WhichFontSize {
     None,
 }
 
+struct StartThreadInLabel {
+    prefix: Option<SharedString>,
+    label: SharedString,
+    suffix: Option<SharedString>,
+}
+
 impl StartThreadIn {
-    fn label(&self) -> SharedString {
+    fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel {
         match self {
-            Self::LocalProject => "Current Worktree".into(),
+            Self::LocalProject => {
+                let suffix = project.active_repository(cx).and_then(|repo| {
+                    let repo = repo.read(cx);
+                    let work_dir = &repo.work_directory_abs_path;
+                    let visible_paths: Vec<_> = project
+                        .visible_worktrees(cx)
+                        .map(|wt| wt.read(cx).abs_path().to_path_buf())
+                        .collect();
+
+                    for linked in repo.linked_worktrees() {
+                        if visible_paths.contains(&linked.path) {
+                            return Some(SharedString::from(format!(
+                                "({})",
+                                linked.display_name()
+                            )));
+                        }
+                    }
+
+                    if visible_paths
+                        .iter()
+                        .any(|p| p.as_path() == work_dir.as_ref())
+                    {
+                        return Some("(main)".into());
+                    }
+
+                    None
+                });
+
+                StartThreadInLabel {
+                    prefix: None,
+                    label: "Current Worktree".into(),
+                    suffix,
+                }
+            }
             Self::NewWorktree {
                 worktree_name: Some(worktree_name),
                 ..
-            } => format!("New: {worktree_name}").into(),
-            Self::NewWorktree { .. } => "New Git Worktree".into(),
-            Self::LinkedWorktree { display_name, .. } => format!("From: {}", &display_name).into(),
+            } => StartThreadInLabel {
+                prefix: Some("New:".into()),
+                label: worktree_name.clone().into(),
+                suffix: None,
+            },
+            Self::NewWorktree { .. } => StartThreadInLabel {
+                prefix: None,
+                label: "New Git Worktree".into(),
+                suffix: None,
+            },
+            Self::LinkedWorktree { display_name, .. } => StartThreadInLabel {
+                prefix: Some("From:".into()),
+                label: display_name.clone().into(),
+                suffix: None,
+            },
         }
     }
 
-    fn worktree_branch_label(&self, default_branch_label: SharedString) -> Option<SharedString> {
+    fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option<StartThreadInLabel> {
         match self {
-            Self::NewWorktree { branch_target, .. } => match branch_target {
-                NewWorktreeBranchTarget::CurrentBranch => Some(default_branch_label),
-                NewWorktreeBranchTarget::ExistingBranch { name } => {
-                    Some(format!("From: {name}").into())
-                }
-                NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
-                    if let Some(from_ref) = from_ref {
-                        Some(format!("From: {from_ref}").into())
-                    } else {
-                        Some(format!("From: {name}").into())
+            Self::NewWorktree { branch_target, .. } => {
+                let (branch_name, is_occupied) = match branch_target {
+                    NewWorktreeBranchTarget::CurrentBranch => {
+                        let name: SharedString = if project.repositories(cx).len() > 1 {
+                            "current branches".into()
+                        } else {
+                            project
+                                .active_repository(cx)
+                                .and_then(|repo| {
+                                    repo.read(cx)
+                                        .branch
+                                        .as_ref()
+                                        .map(|branch| SharedString::from(branch.name().to_string()))
+                                })
+                                .unwrap_or_else(|| "HEAD".into())
+                        };
+                        (name, false)
                     }
-                }
-            },
+                    NewWorktreeBranchTarget::ExistingBranch { name } => {
+                        let occupied = Self::is_branch_occupied(name, project, cx);
+                        (name.clone().into(), occupied)
+                    }
+                    NewWorktreeBranchTarget::CreateBranch {
+                        from_ref: Some(from_ref),
+                        ..
+                    } => {
+                        let occupied = Self::is_branch_occupied(from_ref, project, cx);
+                        (from_ref.clone().into(), occupied)
+                    }
+                    NewWorktreeBranchTarget::CreateBranch { name, .. } => {
+                        (name.clone().into(), false)
+                    }
+                };
+
+                let prefix = if is_occupied {
+                    Some("New From:".into())
+                } else {
+                    None
+                };
+
+                Some(StartThreadInLabel {
+                    prefix,
+                    label: branch_name,
+                    suffix: None,
+                })
+            }
             _ => None,
         }
     }
+
+    fn is_branch_occupied(branch_name: &str, project: &Project, cx: &App) -> bool {
+        project.repositories(cx).values().any(|repo| {
+            repo.read(cx)
+                .linked_worktrees
+                .iter()
+                .any(|wt| wt.branch_name() == Some(branch_name))
+        })
+    }
 }
 
 #[derive(Clone, Debug)]
@@ -3346,7 +3439,9 @@ impl AgentPanel {
             Some(WorktreeCreationStatus::Creating)
         );
 
-        let trigger_label = self.start_thread_in.label();
+        let trigger_parts = self
+            .start_thread_in
+            .trigger_label(self.project.read(cx), cx);
 
         let icon = if self.start_thread_in_menu_handle.is_deployed() {
             IconName::ChevronUp
@@ -3354,9 +3449,16 @@ impl AgentPanel {
             IconName::ChevronDown
         };
 
-        let trigger_button = Button::new("thread-target-trigger", trigger_label)
-            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
-            .disabled(is_creating);
+        let trigger_button = ButtonLike::new("thread-target-trigger")
+            .disabled(is_creating)
+            .when_some(trigger_parts.prefix, |this, prefix| {
+                this.child(Label::new(prefix).color(Color::Muted))
+            })
+            .child(Label::new(trigger_parts.label))
+            .when_some(trigger_parts.suffix, |this, suffix| {
+                this.child(Label::new(suffix).color(Color::Muted))
+            })
+            .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
 
         let project = self.project.clone();
         let current_target = self.start_thread_in.clone();
@@ -3392,37 +3494,31 @@ impl AgentPanel {
             self.worktree_creation_status,
             Some(WorktreeCreationStatus::Creating)
         );
-        let default_branch_label = if self.project.read(cx).repositories(cx).len() > 1 {
-            SharedString::from("From: current branches")
-        } else {
-            self.project
-                .read(cx)
-                .active_repository(cx)
-                .and_then(|repo| {
-                    repo.read(cx)
-                        .branch
-                        .as_ref()
-                        .map(|branch| SharedString::from(format!("From: {}", branch.name())))
-                })
-                .unwrap_or_else(|| SharedString::from("From: HEAD"))
-        };
-        let trigger_label = self
+
+        let project_ref = self.project.read(cx);
+        let trigger_parts = self
             .start_thread_in
-            .worktree_branch_label(default_branch_label)
-            .unwrap_or_else(|| SharedString::from("From: HEAD"));
+            .branch_trigger_label(project_ref, cx)
+            .unwrap_or_else(|| StartThreadInLabel {
+                prefix: Some("From:".into()),
+                label: "HEAD".into(),
+                suffix: None,
+            });
+
         let icon = if self.thread_branch_menu_handle.is_deployed() {
             IconName::ChevronUp
         } else {
             IconName::ChevronDown
         };
-        let trigger_button = Button::new("thread-branch-trigger", trigger_label)
-            .start_icon(
-                Icon::new(IconName::GitBranch)
-                    .size(IconSize::Small)
-                    .color(Color::Muted),
-            )
-            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
-            .disabled(is_creating);
+
+        let trigger_button = ButtonLike::new("thread-branch-trigger")
+            .disabled(is_creating)
+            .when_some(trigger_parts.prefix, |this, prefix| {
+                this.child(Label::new(prefix).color(Color::Muted))
+            })
+            .child(Label::new(trigger_parts.label))
+            .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
+
         let project = self.project.clone();
         let current_target = self.start_thread_in.clone();
 

crates/agent_ui/src/config_options.rs πŸ”—

@@ -3,7 +3,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc};
 use acp_thread::AgentSessionConfigOptions;
 use agent_client_protocol as acp;
 use agent_servers::AgentServer;
-use agent_settings::AgentSettings;
+
 use collections::HashSet;
 use fs::Fs;
 use fuzzy::StringMatchCandidate;
@@ -13,14 +13,13 @@ use gpui::{
 use ordered_float::OrderedFloat;
 use picker::popover_menu::PickerPopoverMenu;
 use picker::{Picker, PickerDelegate};
-use settings::{Settings, SettingsStore};
+use settings::SettingsStore;
 use ui::{
-    DocumentationSide, ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle,
-    Tooltip, prelude::*,
+    ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
 };
 use util::ResultExt as _;
 
-use crate::ui::HoldForDefault;
+use crate::ui::{HoldForDefault, documentation_aside_side};
 
 const PICKER_THRESHOLD: usize = 5;
 
@@ -695,13 +694,7 @@ impl PickerDelegate for ConfigOptionPickerDelegate {
                 let description = description.clone();
                 let is_default = *is_default;
 
-                let settings = AgentSettings::get_global(cx);
-                let side = match settings.dock {
-                    settings::DockPosition::Left => DocumentationSide::Right,
-                    settings::DockPosition::Bottom | settings::DockPosition::Right => {
-                        DocumentationSide::Left
-                    }
-                };
+                let side = documentation_aside_side(cx);
 
                 ui::DocumentationAside::new(
                     side,

crates/agent_ui/src/mode_selector.rs πŸ”—

@@ -1,17 +1,20 @@
 use acp_thread::AgentSessionModes;
 use agent_client_protocol as acp;
 use agent_servers::AgentServer;
-use agent_settings::AgentSettings;
+
 use fs::Fs;
 use gpui::{Context, Entity, WeakEntity, Window, prelude::*};
-use settings::Settings as _;
+
 use std::{rc::Rc, sync::Arc};
 use ui::{
-    Button, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
-    PopoverMenuHandle, Tooltip, prelude::*,
+    Button, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip,
+    prelude::*,
 };
 
-use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault};
+use crate::{
+    CycleModeSelector, ToggleProfileSelector,
+    ui::{HoldForDefault, documentation_aside_side},
+};
 
 pub struct ModeSelector {
     connection: Rc<dyn AgentSessionModes>,
@@ -87,13 +90,7 @@ impl ModeSelector {
             let current_mode = self.connection.current_mode();
             let default_mode = self.agent_server.default_mode(cx);
 
-            let settings = AgentSettings::get_global(cx);
-            let side = match settings.dock {
-                settings::DockPosition::Left => DocumentationSide::Right,
-                settings::DockPosition::Bottom | settings::DockPosition::Right => {
-                    DocumentationSide::Left
-                }
-            };
+            let side = documentation_aside_side(cx);
 
             for mode in all_modes {
                 let is_selected = &mode.id == &current_mode;

crates/agent_ui/src/model_selector.rs πŸ”—

@@ -3,7 +3,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc};
 use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
 use agent_client_protocol::ModelId;
 use agent_servers::AgentServer;
-use agent_settings::AgentSettings;
+
 use anyhow::Result;
 use collections::{HashSet, IndexMap};
 use fs::Fs;
@@ -16,12 +16,15 @@ use gpui::{
 use itertools::Itertools;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
-use settings::{Settings, SettingsStore};
-use ui::{DocumentationAside, DocumentationSide, IntoElement, prelude::*};
+use settings::SettingsStore;
+use ui::{DocumentationAside, IntoElement, prelude::*};
 use util::ResultExt;
 use zed_actions::agent::OpenSettings;
 
-use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
+use crate::ui::{
+    HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem,
+    documentation_aside_side,
+};
 
 pub type ModelSelector = Picker<ModelPickerDelegate>;
 
@@ -385,13 +388,7 @@ impl PickerDelegate for ModelPickerDelegate {
                 let description = description.clone();
                 let is_default = *is_default;
 
-                let settings = AgentSettings::get_global(cx);
-                let side = match settings.dock {
-                    settings::DockPosition::Left => DocumentationSide::Right,
-                    settings::DockPosition::Bottom | settings::DockPosition::Right => {
-                        DocumentationSide::Left
-                    }
-                };
+                let side = documentation_aside_side(cx);
 
                 DocumentationAside::new(
                     side,

crates/agent_ui/src/profile_selector.rs πŸ”—

@@ -1,4 +1,6 @@
-use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector};
+use crate::{
+    CycleModeSelector, ManageProfiles, ToggleProfileSelector, ui::documentation_aside_side,
+};
 use agent_settings::{
     AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles,
 };
@@ -15,8 +17,8 @@ use std::{
     sync::{Arc, atomic::AtomicBool},
 };
 use ui::{
-    DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem,
-    ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
+    DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing,
+    PopoverMenuHandle, Tooltip, prelude::*,
 };
 
 /// Trait for types that can provide and manage agent profiles
@@ -629,13 +631,7 @@ impl PickerDelegate for ProfilePickerDelegate {
         let candidate = self.candidates.get(entry.candidate_index)?;
         let docs_aside = Self::documentation(candidate)?.to_string();
 
-        let settings = AgentSettings::get_global(cx);
-        let side = match settings.dock {
-            settings::DockPosition::Left => DocumentationSide::Right,
-            settings::DockPosition::Bottom | settings::DockPosition::Right => {
-                DocumentationSide::Left
-            }
-        };
+        let side = documentation_aside_side(cx);
 
         Some(DocumentationAside {
             side,

crates/agent_ui/src/thread_branch_picker.rs πŸ”—

@@ -1,4 +1,5 @@
 use std::collections::{HashMap, HashSet};
+use std::rc::Rc;
 
 use collections::HashSet as CollectionsHashSet;
 use std::path::PathBuf;
@@ -7,14 +8,14 @@ use std::sync::Arc;
 use fuzzy::StringMatchCandidate;
 use git::repository::Branch as GitBranch;
 use gpui::{
-    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
-    ParentElement, Render, SharedString, Styled, Task, Window, rems,
+    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems,
 };
 use picker::{Picker, PickerDelegate, PickerEditorPosition};
 use project::Project;
 use ui::{
-    HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip,
-    prelude::*,
+    Divider, DocumentationAside, HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem,
+    ListItemSpacing, prelude::*,
 };
 use util::ResultExt as _;
 
@@ -206,10 +207,10 @@ impl Render for ThreadBranchPicker {
 enum ThreadBranchEntry {
     CurrentBranch,
     DefaultBranch,
+    Separator,
     ExistingBranch {
         branch: GitBranch,
         positions: Vec<usize>,
-        occupied_reason: Option<String>,
     },
     CreateNamed {
         name: String,
@@ -269,42 +270,44 @@ impl ThreadBranchPickerDelegate {
         matches
     }
 
-    fn current_branch_label(&self) -> SharedString {
-        if self.has_multiple_repositories {
-            SharedString::from("New branch from: current branches")
-        } else {
-            SharedString::from(format!("New branch from: {}", self.current_branch_name))
-        }
+    fn is_branch_occupied(&self, branch_name: &str) -> bool {
+        self.occupied_branches
+            .as_ref()
+            .is_some_and(|occupied| occupied.contains_key(branch_name))
     }
 
-    fn default_branch_label(&self) -> Option<SharedString> {
-        let default_branch_name = self
-            .default_branch_name
-            .as_ref()
-            .filter(|name| *name != &self.current_branch_name)?;
-        let is_occupied = self
-            .occupied_branches
-            .as_ref()
-            .is_some_and(|occupied| occupied.contains_key(default_branch_name));
-        let prefix = if is_occupied {
-            "New branch from"
+    fn branch_aside_text(&self, branch_name: &str, is_remote: bool) -> Option<SharedString> {
+        if self.is_branch_occupied(branch_name) {
+            Some(
+                format!(
+                    "This branch is already checked out in another worktree. \
+                     A new branch will be created from {branch_name}."
+                )
+                .into(),
+            )
+        } else if is_remote {
+            Some("A new local branch will be created from this remote branch.".into())
         } else {
-            "From"
-        };
-        Some(SharedString::from(format!(
-            "{prefix}: {default_branch_name}"
-        )))
+            None
+        }
     }
 
-    fn branch_label_prefix(&self, branch_name: &str) -> &'static str {
-        let is_occupied = self
-            .occupied_branches
-            .as_ref()
-            .is_some_and(|occupied| occupied.contains_key(branch_name));
-        if is_occupied {
-            "New branch from: "
-        } else {
-            "From: "
+    fn entry_aside_text(&self, entry: &ThreadBranchEntry) -> Option<SharedString> {
+        match entry {
+            ThreadBranchEntry::CurrentBranch => Some(SharedString::from(
+                "A new branch will be created from the current branch.",
+            )),
+            ThreadBranchEntry::DefaultBranch => {
+                let default_branch_name = self
+                    .default_branch_name
+                    .as_ref()
+                    .filter(|name| *name != &self.current_branch_name)?;
+                self.branch_aside_text(default_branch_name, false)
+            }
+            ThreadBranchEntry::ExistingBranch { branch, .. } => {
+                self.branch_aside_text(branch.name(), branch.is_remote())
+            }
+            _ => None,
         }
     }
 
@@ -379,7 +382,7 @@ impl ThreadBranchPickerDelegate {
 }
 
 impl PickerDelegate for ThreadBranchPickerDelegate {
-    type ListItem = ListItem;
+    type ListItem = AnyElement;
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
         "Search branches…".into()
@@ -406,6 +409,10 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
         self.selected_index = ix;
     }
 
+    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
+        !matches!(self.matches.get(ix), Some(ThreadBranchEntry::Separator))
+    }
+
     fn update_matches(
         &mut self,
         query: String,
@@ -418,10 +425,12 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
             if query.is_empty() {
                 if let Some(name) = self.selected_entry_name().map(ToOwned::to_owned) {
                     if self.prefer_create_entry() {
+                        matches.push(ThreadBranchEntry::Separator);
                         matches.push(ThreadBranchEntry::CreateNamed { name });
                     }
                 }
             } else {
+                matches.push(ThreadBranchEntry::Separator);
                 matches.push(ThreadBranchEntry::CreateNamed {
                     name: query.replace(' ', "-"),
                 });
@@ -437,19 +446,25 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
             self.selected_index = 0;
             return Task::ready(());
         };
-        let occupied_branches = self.occupied_branches.clone().unwrap_or_default();
 
         if query.is_empty() {
             let mut matches = self.fixed_matches();
-            for branch in all_branches.into_iter().filter(|branch| {
-                branch.name() != self.current_branch_name
-                    && self
-                        .default_branch_name
-                        .as_ref()
-                        .is_none_or(|default_branch_name| branch.name() != default_branch_name)
-            }) {
+            let filtered_branches: Vec<_> = all_branches
+                .into_iter()
+                .filter(|branch| {
+                    branch.name() != self.current_branch_name
+                        && self
+                            .default_branch_name
+                            .as_ref()
+                            .is_none_or(|default_branch_name| branch.name() != default_branch_name)
+                })
+                .collect();
+
+            if !filtered_branches.is_empty() {
+                matches.push(ThreadBranchEntry::Separator);
+            }
+            for branch in filtered_branches {
                 matches.push(ThreadBranchEntry::ExistingBranch {
-                    occupied_reason: occupied_branches.get(branch.name()).cloned(),
                     branch,
                     positions: Vec::new(),
                 });
@@ -504,6 +519,7 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
             picker
                 .update_in(cx, |picker, _window, cx| {
                     let mut matches = picker.delegate.fixed_matches();
+                    let mut has_dynamic_entries = false;
 
                     for candidate in &fuzzy_matches {
                         let branch = all_branches_clone[candidate.candidate_id].clone();
@@ -514,15 +530,20 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
                         {
                             continue;
                         }
-                        let occupied_reason = occupied_branches.get(branch.name()).cloned();
+                        if !has_dynamic_entries {
+                            matches.push(ThreadBranchEntry::Separator);
+                            has_dynamic_entries = true;
+                        }
                         matches.push(ThreadBranchEntry::ExistingBranch {
                             branch,
                             positions: candidate.positions.clone(),
-                            occupied_reason,
                         });
                     }
 
                     if fuzzy_matches.is_empty() {
+                        if !has_dynamic_entries {
+                            matches.push(ThreadBranchEntry::Separator);
+                        }
                         matches.push(ThreadBranchEntry::CreateNamed {
                             name: normalized_query.clone(),
                         });
@@ -558,6 +579,7 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
         };
 
         match entry {
+            ThreadBranchEntry::Separator => return,
             ThreadBranchEntry::CurrentBranch => {
                 window.dispatch_action(
                     Box::new(self.new_worktree_action(NewWorktreeBranchTarget::CurrentBranch)),
@@ -615,76 +637,97 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
 
     fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
 
-    fn separators_after_indices(&self) -> Vec<usize> {
-        let fixed_count = self.fixed_matches().len();
-        if self.matches.len() > fixed_count {
-            vec![fixed_count - 1]
-        } else {
-            Vec::new()
-        }
-    }
-
     fn render_match(
         &self,
         ix: usize,
         selected: bool,
         _window: &mut Window,
-        _cx: &mut Context<Picker<Self>>,
+        cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let entry = self.matches.get(ix)?;
 
         match entry {
-            ThreadBranchEntry::CurrentBranch => Some(
-                ListItem::new("current-branch")
-                    .inset(true)
-                    .spacing(ListItemSpacing::Sparse)
-                    .toggle_state(selected)
-                    .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
-                    .child(Label::new(self.current_branch_label())),
+            ThreadBranchEntry::Separator => Some(
+                div()
+                    .py(DynamicSpacing::Base04.rems(cx))
+                    .child(Divider::horizontal())
+                    .into_any_element(),
             ),
-            ThreadBranchEntry::DefaultBranch => Some(
-                ListItem::new("default-branch")
+            ThreadBranchEntry::CurrentBranch => {
+                let branch_name = if self.has_multiple_repositories {
+                    SharedString::from("current branches")
+                } else {
+                    SharedString::from(self.current_branch_name.clone())
+                };
+
+                Some(
+                    ListItem::new("current-branch")
+                        .inset(true)
+                        .spacing(ListItemSpacing::Sparse)
+                        .toggle_state(selected)
+                        .child(Label::new(branch_name))
+                        .into_any_element(),
+                )
+            }
+            ThreadBranchEntry::DefaultBranch => {
+                let default_branch_name = self
+                    .default_branch_name
+                    .as_ref()
+                    .filter(|name| *name != &self.current_branch_name)?;
+                let is_occupied = self.is_branch_occupied(default_branch_name);
+
+                let item = ListItem::new("default-branch")
                     .inset(true)
                     .spacing(ListItemSpacing::Sparse)
                     .toggle_state(selected)
-                    .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
-                    .child(Label::new(self.default_branch_label()?)),
-            ),
+                    .child(Label::new(default_branch_name.clone()));
+
+                Some(
+                    if is_occupied {
+                        item.start_slot(Icon::new(IconName::GitBranchPlus).color(Color::Muted))
+                    } else {
+                        item
+                    }
+                    .into_any_element(),
+                )
+            }
             ThreadBranchEntry::ExistingBranch {
-                branch,
-                positions,
-                occupied_reason,
+                branch, positions, ..
             } => {
-                let prefix = self.branch_label_prefix(branch.name());
                 let branch_name = branch.name().to_string();
-                let full_label = format!("{prefix}{branch_name}");
-                let adjusted_positions: Vec<usize> =
-                    positions.iter().map(|&p| p + prefix.len()).collect();
-
-                let item = ListItem::new(SharedString::from(format!("branch-{ix}")))
-                    .inset(true)
-                    .spacing(ListItemSpacing::Sparse)
-                    .toggle_state(selected)
-                    .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
-                    .child(HighlightedLabel::new(full_label, adjusted_positions).truncate());
-
-                Some(if let Some(reason) = occupied_reason.clone() {
-                    item.tooltip(Tooltip::text(reason))
-                } else if branch.is_remote() {
-                    item.tooltip(Tooltip::text(
-                        "Create a new local branch from this remote branch",
-                    ))
-                } else {
-                    item
-                })
+                let needs_new_branch = self.is_branch_occupied(&branch_name) || branch.is_remote();
+
+                Some(
+                    ListItem::new(SharedString::from(format!("branch-{ix}")))
+                        .inset(true)
+                        .spacing(ListItemSpacing::Sparse)
+                        .toggle_state(selected)
+                        .child(
+                            h_flex()
+                                .min_w_0()
+                                .gap_1()
+                                .child(
+                                    HighlightedLabel::new(branch_name, positions.clone())
+                                        .truncate(),
+                                )
+                                .when(needs_new_branch, |item| {
+                                    item.child(
+                                        Icon::new(IconName::GitBranchPlus)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                }),
+                        )
+                        .into_any_element(),
+                )
             }
             ThreadBranchEntry::CreateNamed { name } => Some(
                 ListItem::new("create-named-branch")
                     .inset(true)
                     .spacing(ListItemSpacing::Sparse)
                     .toggle_state(selected)
-                    .start_slot(Icon::new(IconName::Plus).color(Color::Accent))
-                    .child(Label::new(format!("Create Branch: \"{name}\"…"))),
+                    .child(Label::new(format!("Create Branch: \"{name}\"…")))
+                    .into_any_element(),
             ),
         }
     }
@@ -692,4 +735,24 @@ impl PickerDelegate for ThreadBranchPickerDelegate {
     fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
         None
     }
+
+    fn documentation_aside(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<DocumentationAside> {
+        let entry = self.matches.get(self.selected_index)?;
+        let aside_text = self.entry_aside_text(entry)?;
+        let side = crate::ui::documentation_aside_side(cx);
+
+        Some(DocumentationAside::new(
+            side,
+            Rc::new(move |_| Label::new(aside_text.clone()).into_any_element()),
+        ))
+    }
+
+    fn documentation_aside_index(&self) -> Option<usize> {
+        let entry = self.matches.get(self.selected_index)?;
+        self.entry_aside_text(entry).map(|_| self.selected_index)
+    }
 }

crates/agent_ui/src/thread_worktree_picker.rs πŸ”—

@@ -1,4 +1,5 @@
 use std::path::PathBuf;
+use std::rc::Rc;
 use std::sync::Arc;
 
 use agent_settings::AgentSettings;
@@ -6,17 +7,18 @@ use fs::Fs;
 use fuzzy::StringMatchCandidate;
 use git::repository::Worktree as GitWorktree;
 use gpui::{
-    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
-    ParentElement, Render, SharedString, Styled, Task, Window, rems,
+    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems,
 };
 use picker::{Picker, PickerDelegate, PickerEditorPosition};
 use project::{Project, git_store::RepositoryId};
 use settings::{NewThreadLocation, Settings, update_settings_file};
 use ui::{
-    HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip,
-    prelude::*,
+    Divider, DocumentationAside, HighlightedLabel, Label, LabelCommon, ListItem, ListItemSpacing,
+    Tooltip, prelude::*,
 };
 use util::ResultExt as _;
+use util::paths::PathExt;
 
 use crate::ui::HoldForDefault;
 use crate::{NewWorktreeBranchTarget, StartThreadIn};
@@ -46,24 +48,60 @@ impl ThreadWorktreePicker {
             _ => NewWorktreeBranchTarget::default(),
         };
 
-        let delegate = ThreadWorktreePickerDelegate {
-            matches: vec![
-                ThreadWorktreeEntry::CurrentWorktree,
-                ThreadWorktreeEntry::NewWorktree,
-            ],
-            all_worktrees: project
-                .read(cx)
-                .repositories(cx)
+        let all_worktrees: Vec<_> = project
+            .read(cx)
+            .repositories(cx)
+            .iter()
+            .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone()))
+            .collect();
+
+        let has_multiple_repositories = all_worktrees.len() > 1;
+
+        let linked_worktrees: Vec<_> = if has_multiple_repositories {
+            Vec::new()
+        } else {
+            all_worktrees
                 .iter()
-                .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone()))
-                .collect(),
+                .flat_map(|(_, worktrees)| worktrees.iter())
+                .filter(|worktree| {
+                    !project_worktree_paths
+                        .iter()
+                        .any(|project_path| project_path == &worktree.path)
+                })
+                .cloned()
+                .collect()
+        };
+
+        let mut initial_matches = vec![
+            ThreadWorktreeEntry::CurrentWorktree,
+            ThreadWorktreeEntry::NewWorktree,
+        ];
+
+        if !linked_worktrees.is_empty() {
+            initial_matches.push(ThreadWorktreeEntry::Separator);
+            for worktree in &linked_worktrees {
+                initial_matches.push(ThreadWorktreeEntry::LinkedWorktree {
+                    worktree: worktree.clone(),
+                    positions: Vec::new(),
+                });
+            }
+        }
+
+        let selected_index = match current_target {
+            StartThreadIn::LocalProject => 0,
+            StartThreadIn::NewWorktree { .. } => 1,
+            StartThreadIn::LinkedWorktree { path, .. } => initial_matches
+                .iter()
+                .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { worktree, .. } if worktree.path == *path))
+                .unwrap_or(0),
+        };
+
+        let delegate = ThreadWorktreePickerDelegate {
+            matches: initial_matches,
+            all_worktrees,
             project_worktree_paths,
-            selected_index: match current_target {
-                StartThreadIn::LocalProject => 0,
-                StartThreadIn::NewWorktree { .. } => 1,
-                _ => 0,
-            },
-            project: project.clone(),
+            selected_index,
+            project,
             preserved_branch_target,
             fs,
         };
@@ -111,6 +149,7 @@ impl Render for ThreadWorktreePicker {
 enum ThreadWorktreeEntry {
     CurrentWorktree,
     NewWorktree,
+    Separator,
     LinkedWorktree {
         worktree: GitWorktree,
         positions: Vec<usize>,
@@ -139,7 +178,11 @@ impl ThreadWorktreePickerDelegate {
         }
     }
 
-    fn sync_selected_index(&mut self) {
+    fn sync_selected_index(&mut self, has_query: bool) {
+        if !has_query {
+            return;
+        }
+
         if let Some(index) = self
             .matches
             .iter()
@@ -159,7 +202,7 @@ impl ThreadWorktreePickerDelegate {
 }
 
 impl PickerDelegate for ThreadWorktreePickerDelegate {
-    type ListItem = ListItem;
+    type ListItem = AnyElement;
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
         "Search or create worktrees…".into()
@@ -186,12 +229,8 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
         self.selected_index = ix;
     }
 
-    fn separators_after_indices(&self) -> Vec<usize> {
-        if self.matches.len() > 2 {
-            vec![1]
-        } else {
-            Vec::new()
-        }
+    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
+        !matches!(self.matches.get(ix), Some(ThreadWorktreeEntry::Separator))
     }
 
     fn update_matches(
@@ -238,6 +277,9 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
         ];
 
         if query.is_empty() {
+            if !linked_worktrees.is_empty() {
+                matches.push(ThreadWorktreeEntry::Separator);
+            }
             for worktree in &linked_worktrees {
                 matches.push(ThreadWorktreeEntry::LinkedWorktree {
                     worktree: worktree.clone(),
@@ -245,6 +287,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
                 });
             }
         } else if linked_worktrees.is_empty() {
+            matches.push(ThreadWorktreeEntry::Separator);
             matches.push(ThreadWorktreeEntry::CreateNamed {
                 name: normalized_query,
                 disabled_reason: create_named_disabled_reason,
@@ -283,6 +326,12 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
                             ThreadWorktreeEntry::NewWorktree,
                         ];
 
+                        let has_extra_entries = !fuzzy_matches.is_empty();
+
+                        if has_extra_entries {
+                            new_matches.push(ThreadWorktreeEntry::Separator);
+                        }
+
                         for candidate in &fuzzy_matches {
                             new_matches.push(ThreadWorktreeEntry::LinkedWorktree {
                                 worktree: linked_worktrees_clone[candidate.candidate_id].clone(),
@@ -295,6 +344,9 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
                             .any(|worktree| worktree.display_name() == query);
 
                         if !has_exact_match {
+                            if !has_extra_entries {
+                                new_matches.push(ThreadWorktreeEntry::Separator);
+                            }
                             new_matches.push(ThreadWorktreeEntry::CreateNamed {
                                 name: normalized_query.clone(),
                                 disabled_reason: create_named_disabled_reason.clone(),
@@ -302,7 +354,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
                         }
 
                         picker.delegate.matches = new_matches;
-                        picker.delegate.sync_selected_index();
+                        picker.delegate.sync_selected_index(true);
 
                         cx.notify();
                     })
@@ -311,7 +363,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
         }
 
         self.matches = matches;
-        self.sync_selected_index();
+        self.sync_selected_index(!query.is_empty());
 
         Task::ready(())
     }
@@ -322,6 +374,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
         };
 
         match entry {
+            ThreadWorktreeEntry::Separator => return,
             ThreadWorktreeEntry::CurrentWorktree => {
                 if secondary {
                     update_settings_file(self.fs.clone(), cx, |settings, _| {
@@ -383,48 +436,73 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
         let project = self.project.read(cx);
         let is_new_worktree_disabled =
             project.repositories(cx).is_empty() || project.is_via_collab();
-        let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
-        let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
-        let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
 
         match entry {
-            ThreadWorktreeEntry::CurrentWorktree => Some(
-                ListItem::new("current-worktree")
-                    .inset(true)
-                    .spacing(ListItemSpacing::Sparse)
-                    .toggle_state(selected)
-                    .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
-                    .child(Label::new("Current Worktree"))
-                    .end_slot(HoldForDefault::new(is_local_default).more_content(false))
-                    .tooltip(Tooltip::text("Use the current project worktree")),
+            ThreadWorktreeEntry::Separator => Some(
+                div()
+                    .py(DynamicSpacing::Base04.rems(cx))
+                    .child(Divider::horizontal())
+                    .into_any_element(),
             ),
+            ThreadWorktreeEntry::CurrentWorktree => {
+                let path_label = project.active_repository(cx).map(|repo| {
+                    let path = repo.read(cx).work_directory_abs_path.clone();
+                    path.compact().to_string_lossy().to_string()
+                });
+
+                Some(
+                    ListItem::new("current-worktree")
+                        .inset(true)
+                        .spacing(ListItemSpacing::Sparse)
+                        .toggle_state(selected)
+                        .child(
+                            v_flex()
+                                .min_w_0()
+                                .overflow_hidden()
+                                .child(Label::new("Current Worktree"))
+                                .when_some(path_label, |this, path| {
+                                    this.child(
+                                        Label::new(path)
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted)
+                                            .truncate_start(),
+                                    )
+                                }),
+                        )
+                        .into_any_element(),
+                )
+            }
             ThreadWorktreeEntry::NewWorktree => {
                 let item = ListItem::new("new-worktree")
                     .inset(true)
                     .spacing(ListItemSpacing::Sparse)
                     .toggle_state(selected)
                     .disabled(is_new_worktree_disabled)
-                    .start_slot(
-                        Icon::new(IconName::Plus).color(if is_new_worktree_disabled {
-                            Color::Disabled
-                        } else {
-                            Color::Muted
-                        }),
-                    )
                     .child(
-                        Label::new("New Git Worktree").color(if is_new_worktree_disabled {
-                            Color::Disabled
-                        } else {
-                            Color::Default
-                        }),
+                        v_flex()
+                            .min_w_0()
+                            .overflow_hidden()
+                            .child(
+                                Label::new("New Git Worktree")
+                                    .when(is_new_worktree_disabled, |this| {
+                                        this.color(Color::Disabled)
+                                    }),
+                            )
+                            .child(
+                                Label::new("Get a fresh new worktree")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            ),
                     );
 
-                Some(if is_new_worktree_disabled {
-                    item.tooltip(Tooltip::text("Requires a Git repository in the project"))
-                } else {
-                    item.end_slot(HoldForDefault::new(is_new_worktree_default).more_content(false))
-                        .tooltip(Tooltip::text("Start a thread in a new Git worktree"))
-                })
+                Some(
+                    if is_new_worktree_disabled {
+                        item.tooltip(Tooltip::text("Requires a Git repository in the project"))
+                    } else {
+                        item
+                    }
+                    .into_any_element(),
+                )
             }
             ThreadWorktreeEntry::LinkedWorktree {
                 worktree,
@@ -437,14 +515,29 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
                     .copied()
                     .filter(|&pos| pos < first_line.len())
                     .collect();
+                let path = worktree.path.compact();
 
                 Some(
                     ListItem::new(SharedString::from(format!("linked-worktree-{ix}")))
                         .inset(true)
                         .spacing(ListItemSpacing::Sparse)
                         .toggle_state(selected)
-                        .start_slot(Icon::new(IconName::GitWorktree).color(Color::Muted))
-                        .child(HighlightedLabel::new(first_line.to_owned(), positions).truncate()),
+                        .child(
+                            v_flex()
+                                .min_w_0()
+                                .overflow_hidden()
+                                .child(
+                                    HighlightedLabel::new(first_line.to_owned(), positions)
+                                        .truncate(),
+                                )
+                                .child(
+                                    Label::new(path.to_string_lossy().to_string())
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted)
+                                        .truncate_start(),
+                                ),
+                        )
+                        .into_any_element(),
                 )
             }
             ThreadWorktreeEntry::CreateNamed {
@@ -457,11 +550,6 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
                     .spacing(ListItemSpacing::Sparse)
                     .toggle_state(selected)
                     .disabled(is_disabled)
-                    .start_slot(Icon::new(IconName::Plus).color(if is_disabled {
-                        Color::Disabled
-                    } else {
-                        Color::Accent
-                    }))
                     .child(Label::new(format!("Create Worktree: \"{name}\"…")).color(
                         if is_disabled {
                             Color::Disabled
@@ -470,11 +558,14 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
                         },
                     ));
 
-                Some(if let Some(reason) = disabled_reason.clone() {
-                    item.tooltip(Tooltip::text(reason))
-                } else {
-                    item
-                })
+                Some(
+                    if let Some(reason) = disabled_reason.clone() {
+                        item.tooltip(Tooltip::text(reason))
+                    } else {
+                        item
+                    }
+                    .into_any_element(),
+                )
             }
         }
     }
@@ -482,4 +573,49 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
     fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
         None
     }
+
+    fn documentation_aside(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<DocumentationAside> {
+        let entry = self.matches.get(self.selected_index)?;
+        let is_default = match entry {
+            ThreadWorktreeEntry::CurrentWorktree => {
+                let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
+                Some(new_thread_location == NewThreadLocation::LocalProject)
+            }
+            ThreadWorktreeEntry::NewWorktree => {
+                let project = self.project.read(cx);
+                let is_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
+                if is_disabled {
+                    None
+                } else {
+                    let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
+                    Some(new_thread_location == NewThreadLocation::NewWorktree)
+                }
+            }
+            _ => None,
+        }?;
+
+        let side = crate::ui::documentation_aside_side(cx);
+
+        Some(DocumentationAside::new(
+            side,
+            Rc::new(move |_| {
+                HoldForDefault::new(is_default)
+                    .more_content(false)
+                    .into_any_element()
+            }),
+        ))
+    }
+
+    fn documentation_aside_index(&self) -> Option<usize> {
+        match self.matches.get(self.selected_index) {
+            Some(ThreadWorktreeEntry::CurrentWorktree | ThreadWorktreeEntry::NewWorktree) => {
+                Some(self.selected_index)
+            }
+            _ => None,
+        }
+    }
 }

crates/agent_ui/src/ui.rs πŸ”—

@@ -13,3 +13,16 @@ pub use hold_for_default::*;
 pub use mention_crease::*;
 pub use model_selector_components::*;
 pub use undo_reject_toast::*;
+
+/// Returns the appropriate [`DocumentationSide`] for documentation asides
+/// in the agent panel, based on the current dock position.
+pub fn documentation_aside_side(cx: &gpui::App) -> ui::DocumentationSide {
+    use agent_settings::AgentSettings;
+    use settings::Settings;
+    use ui::DocumentationSide;
+
+    match AgentSettings::get_global(cx).dock {
+        settings::DockPosition::Left => DocumentationSide::Right,
+        settings::DockPosition::Bottom | settings::DockPosition::Right => DocumentationSide::Left,
+    }
+}