sidebar: Adjust thread switcher modal (#52788)

Danilo Leal created

This PR makes use of the `ThreadItem` component for the thread switcher,
as opposed to recreating it locally. Added some additional methods to it
so as to nicely fit in the switcher modal. Am also rendering the project
name, given that it felt like a major piece of context, given that
without the sidebar open, you'd potentially feel a bit lost without it.

Release Notes:

- N/A

Change summary

crates/sidebar/src/sidebar.rs              |  18 +
crates/sidebar/src/thread_switcher.rs      | 205 +++--------------------
crates/ui/src/components/ai/thread_item.rs |  56 +++++
3 files changed, 96 insertions(+), 183 deletions(-)

Detailed changes

crates/sidebar/src/sidebar.rs 🔗

@@ -2482,13 +2482,17 @@ impl Sidebar {
     }
 
     fn mru_threads_for_switcher(&self, _cx: &App) -> Vec<ThreadSwitcherEntry> {
+        let mut current_header_label: Option<SharedString> = None;
         let mut current_header_workspace: Option<Entity<Workspace>> = None;
         let mut entries: Vec<ThreadSwitcherEntry> = self
             .contents
             .entries
             .iter()
             .filter_map(|entry| match entry {
-                ListEntry::ProjectHeader { workspace, .. } => {
+                ListEntry::ProjectHeader {
+                    label, workspace, ..
+                } => {
+                    current_header_label = Some(label.clone());
                     current_header_workspace = Some(workspace.clone());
                     None
                 }
@@ -2518,8 +2522,16 @@ impl Sidebar {
                         status: thread.status,
                         metadata: thread.metadata.clone(),
                         workspace,
-                        worktree_name: thread.worktrees.first().map(|wt| wt.name.clone()),
-
+                        project_name: current_header_label.clone(),
+                        worktrees: thread
+                            .worktrees
+                            .iter()
+                            .map(|wt| ThreadItemWorktreeInfo {
+                                name: wt.name.clone(),
+                                full_path: wt.full_path.clone(),
+                                highlight_positions: Vec::new(),
+                            })
+                            .collect(),
                         diff_stats: thread.diff_stats,
                         is_title_generating: thread.is_title_generating,
                         notified,

crates/sidebar/src/thread_switcher.rs 🔗

@@ -2,20 +2,13 @@ use action_log::DiffStats;
 use agent_client_protocol as acp;
 use agent_ui::thread_metadata_store::ThreadMetadata;
 use gpui::{
-    Action as _, Animation, AnimationExt, AnyElement, DismissEvent, Entity, EventEmitter,
-    FocusHandle, Focusable, Hsla, Modifiers, ModifiersChangedEvent, Render, SharedString,
-    prelude::*, pulsating_between,
-};
-use std::time::Duration;
-use ui::{
-    AgentThreadStatus, Color, CommonAnimationExt, DecoratedIcon, DiffStat, Icon, IconDecoration,
-    IconDecorationKind, IconName, IconSize, Label, LabelSize, prelude::*,
+    Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers,
+    ModifiersChangedEvent, Render, SharedString, prelude::*,
 };
+use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, prelude::*};
 use workspace::{ModalView, Workspace};
 use zed_actions::agents_sidebar::ToggleThreadSwitcher;
 
-const PANEL_WIDTH_REMS: f32 = 28.;
-
 pub(crate) struct ThreadSwitcherEntry {
     pub session_id: acp::SessionId,
     pub title: SharedString,
@@ -24,7 +17,8 @@ pub(crate) struct ThreadSwitcherEntry {
     pub status: AgentThreadStatus,
     pub metadata: ThreadMetadata,
     pub workspace: Entity<Workspace>,
-    pub worktree_name: Option<SharedString>,
+    pub project_name: Option<SharedString>,
+    pub worktrees: Vec<ThreadItemWorktreeInfo>,
     pub diff_stats: DiffStats,
     pub is_title_generating: bool,
     pub notified: bool,
@@ -193,179 +187,44 @@ impl Focusable for ThreadSwitcher {
 impl Render for ThreadSwitcher {
     fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
         let selected_index = self.selected_index;
-        let color = cx.theme().colors();
-        let panel_bg = color
-            .title_bar_background
-            .blend(color.panel_background.opacity(0.2));
 
         v_flex()
             .key_context("ThreadSwitcher")
             .track_focus(&self.focus_handle)
-            .w(gpui::rems(PANEL_WIDTH_REMS))
+            .w(rems_from_px(440.))
+            .p_1p5()
+            .gap_0p5()
             .elevation_3(cx)
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .on_action(cx.listener(Self::confirm))
             .on_action(cx.listener(Self::cancel))
             .on_action(cx.listener(Self::toggle))
             .children(self.entries.iter().enumerate().map(|(ix, entry)| {
-                let is_first = ix == 0;
-                let is_last = ix == self.entries.len() - 1;
-                let selected = ix == selected_index;
-                let base_bg = if selected {
-                    color.element_active
-                } else {
-                    panel_bg
-                };
-
-                let dot_separator = || {
-                    Label::new("\u{2022}")
-                        .size(LabelSize::Small)
-                        .color(Color::Muted)
-                        .alpha(0.5)
-                };
-
-                let icon_container = || h_flex().size_4().flex_none().justify_center();
-
-                let agent_icon = || {
-                    if let Some(ref svg) = entry.icon_from_external_svg {
-                        Icon::from_external_svg(svg.clone())
-                            .color(Color::Muted)
-                            .size(IconSize::Small)
-                    } else {
-                        Icon::new(entry.icon)
-                            .color(Color::Muted)
-                            .size(IconSize::Small)
-                    }
-                };
-
-                let decoration = |kind: IconDecorationKind, deco_color: Hsla| {
-                    IconDecoration::new(kind, base_bg, cx)
-                        .color(deco_color)
-                        .position(gpui::Point {
-                            x: px(-2.),
-                            y: px(-2.),
-                        })
-                };
-
-                let icon_element: AnyElement = if entry.status == AgentThreadStatus::Running {
-                    icon_container()
-                        .child(
-                            Icon::new(IconName::LoadCircle)
-                                .size(IconSize::Small)
-                                .color(Color::Muted)
-                                .with_rotate_animation(2),
-                        )
-                        .into_any_element()
-                } else if entry.status == AgentThreadStatus::Error {
-                    icon_container()
-                        .child(DecoratedIcon::new(
-                            agent_icon(),
-                            Some(decoration(IconDecorationKind::X, cx.theme().status().error)),
-                        ))
-                        .into_any_element()
-                } else if entry.status == AgentThreadStatus::WaitingForConfirmation {
-                    icon_container()
-                        .child(DecoratedIcon::new(
-                            agent_icon(),
-                            Some(decoration(
-                                IconDecorationKind::Triangle,
-                                cx.theme().status().warning,
-                            )),
-                        ))
-                        .into_any_element()
-                } else if entry.notified {
-                    icon_container()
-                        .child(DecoratedIcon::new(
-                            agent_icon(),
-                            Some(decoration(IconDecorationKind::Dot, color.text_accent)),
-                        ))
-                        .into_any_element()
-                } else {
-                    icon_container().child(agent_icon()).into_any_element()
-                };
-
-                let title_label: AnyElement = if entry.is_title_generating {
-                    Label::new(entry.title.clone())
-                        .color(Color::Muted)
-                        .with_animation(
-                            "generating-title",
-                            Animation::new(Duration::from_secs(2))
-                                .repeat()
-                                .with_easing(pulsating_between(0.4, 0.8)),
-                            |label, delta| label.alpha(delta),
-                        )
-                        .into_any_element()
-                } else {
-                    Label::new(entry.title.clone()).into_any_element()
-                };
-
-                let has_diff_stats =
-                    entry.diff_stats.lines_added > 0 || entry.diff_stats.lines_removed > 0;
-                let has_worktree = entry.worktree_name.is_some();
-                let has_timestamp = !entry.timestamp.is_empty();
-
-                v_flex()
-                    .id(ix)
-                    .w_full()
-                    .py_1()
-                    .px_1p5()
-                    .border_1()
-                    .border_color(gpui::transparent_black())
-                    .when(selected, |s| s.bg(color.element_active))
-                    .when(is_first, |s| s.rounded_t_lg())
-                    .when(is_last, |s| s.rounded_b_lg())
-                    .child(
-                        h_flex()
-                            .min_w_0()
-                            .w_full()
-                            .gap_1p5()
-                            .child(icon_element)
-                            .child(title_label),
-                    )
-                    .when(has_worktree || has_diff_stats || has_timestamp, |this| {
-                        this.child(
-                            h_flex()
-                                .min_w_0()
-                                .gap_1p5()
-                                .child(icon_container())
-                                .when_some(entry.worktree_name.clone(), |this, worktree| {
-                                    this.child(
-                                        h_flex()
-                                            .gap_1()
-                                            .child(
-                                                Icon::new(IconName::GitWorktree)
-                                                    .size(IconSize::XSmall)
-                                                    .color(Color::Muted),
-                                            )
-                                            .child(
-                                                Label::new(worktree)
-                                                    .size(LabelSize::Small)
-                                                    .color(Color::Muted),
-                                            ),
-                                    )
-                                })
-                                .when(has_worktree && (has_diff_stats || has_timestamp), |this| {
-                                    this.child(dot_separator())
-                                })
-                                .when(has_diff_stats, |this| {
-                                    this.child(DiffStat::new(
-                                        ix,
-                                        entry.diff_stats.lines_added as usize,
-                                        entry.diff_stats.lines_removed as usize,
-                                    ))
-                                })
-                                .when(has_diff_stats && has_timestamp, |this| {
-                                    this.child(dot_separator())
-                                })
-                                .when(has_timestamp, |this| {
-                                    this.child(
-                                        Label::new(entry.timestamp.clone())
-                                            .size(LabelSize::Small)
-                                            .color(Color::Muted),
-                                    )
-                                }),
-                        )
+                let id = SharedString::from(format!("thread-switcher-{}", entry.session_id));
+
+                ThreadItem::new(id, entry.title.clone())
+                    .rounded(true)
+                    .icon(entry.icon)
+                    .status(entry.status)
+                    .when_some(entry.icon_from_external_svg.clone(), |this, svg| {
+                        this.custom_icon_from_external_svg(svg)
+                    })
+                    .when_some(entry.project_name.clone(), |this, name| {
+                        this.project_name(name)
+                    })
+                    .worktrees(entry.worktrees.clone())
+                    .timestamp(entry.timestamp.clone())
+                    .title_generating(entry.is_title_generating)
+                    .notified(entry.notified)
+                    .when(entry.diff_stats.lines_added > 0, |this| {
+                        this.added(entry.diff_stats.lines_added as usize)
+                    })
+                    .when(entry.diff_stats.lines_removed > 0, |this| {
+                        this.removed(entry.diff_stats.lines_removed as usize)
                     })
+                    .selected(ix == selected_index)
+                    .base_bg(cx.theme().colors().surface_background)
+                    .into_any_element()
             }))
     }
 }

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

@@ -43,14 +43,17 @@ pub struct ThreadItem {
     selected: bool,
     focused: bool,
     hovered: bool,
+    rounded: bool,
     added: Option<usize>,
     removed: Option<usize>,
     project_paths: Option<Arc<[PathBuf]>>,
+    project_name: Option<SharedString>,
     worktrees: Vec<ThreadItemWorktreeInfo>,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
     action_slot: Option<AnyElement>,
     tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
+    base_bg: Option<Hsla>,
 }
 
 impl ThreadItem {
@@ -71,15 +74,18 @@ impl ThreadItem {
             selected: false,
             focused: false,
             hovered: false,
+            rounded: false,
             added: None,
             removed: None,
 
             project_paths: None,
+            project_name: None,
             worktrees: Vec::new(),
             on_click: None,
             on_hover: Box::new(|_, _, _| {}),
             action_slot: None,
             tooltip: None,
+            base_bg: None,
         }
     }
 
@@ -158,6 +164,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn project_name(mut self, name: impl Into<SharedString>) -> Self {
+        self.project_name = Some(name.into());
+        self
+    }
+
     pub fn worktrees(mut self, worktrees: Vec<ThreadItemWorktreeInfo>) -> Self {
         self.worktrees = worktrees;
         self
@@ -168,6 +179,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn rounded(mut self, rounded: bool) -> Self {
+        self.rounded = rounded;
+        self
+    }
+
     pub fn on_click(
         mut self,
         handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -190,15 +206,22 @@ impl ThreadItem {
         self.tooltip = Some(Box::new(tooltip));
         self
     }
+
+    pub fn base_bg(mut self, color: Hsla) -> Self {
+        self.base_bg = Some(color);
+        self
+    }
 }
 
 impl RenderOnce for ThreadItem {
     fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
         let color = cx.theme().colors();
-        let base_bg = color
+        let sidebar_base_bg = color
             .title_bar_background
             .blend(color.panel_background.opacity(0.2));
 
+        let base_bg = self.base_bg.unwrap_or(sidebar_base_bg);
+
         let base_bg = if self.selected {
             color.element_active
         } else {
@@ -335,6 +358,7 @@ impl RenderOnce for ThreadItem {
             }
         });
 
+        let has_project_name = self.project_name.is_some();
         let has_project_paths = project_paths.is_some();
         let has_worktree = !self.worktrees.is_empty();
         let has_timestamp = !self.timestamp.is_empty();
@@ -353,6 +377,7 @@ impl RenderOnce for ThreadItem {
             .border_1()
             .border_color(gpui::transparent_black())
             .when(self.focused, |s| s.border_color(color.border_focused))
+            .when(self.rounded, |s| s.rounded_sm())
             .hover(|s| s.bg(hover_color))
             .on_hover(self.on_hover)
             .child(
@@ -393,7 +418,11 @@ impl RenderOnce for ThreadItem {
                     }),
             )
             .when(
-                has_project_paths || has_worktree || has_diff_stats || has_timestamp,
+                has_project_name
+                    || has_project_paths
+                    || has_worktree
+                    || has_diff_stats
+                    || has_timestamp,
                 |this| {
                     // Collect all full paths for the shared tooltip.
                     let worktree_tooltip: SharedString = self
@@ -413,13 +442,16 @@ impl RenderOnce for ThreadItem {
                     // "olivetti" produce a single chip. Highlight positions
                     // come from the first occurrence.
                     let mut seen_names: Vec<SharedString> = Vec::new();
-                    let mut worktree_chips: Vec<AnyElement> = Vec::new();
+                    let mut worktree_labels: Vec<AnyElement> = Vec::new();
+
                     for wt in self.worktrees {
                         if seen_names.contains(&wt.name) {
                             continue;
                         }
+
                         let chip_index = seen_names.len();
                         seen_names.push(wt.name.clone());
+
                         let label = if wt.highlight_positions.is_empty() {
                             Label::new(wt.name)
                                 .size(LabelSize::Small)
@@ -433,7 +465,8 @@ impl RenderOnce for ThreadItem {
                         };
                         let tooltip_title = worktree_tooltip_title;
                         let tooltip_meta = worktree_tooltip.clone();
-                        worktree_chips.push(
+
+                        worktree_labels.push(
                             h_flex()
                                 .id(format!("{}-worktree-{chip_index}", self.id.clone()))
                                 .gap_0p5()
@@ -460,6 +493,15 @@ impl RenderOnce for ThreadItem {
                             .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()),
+                            )
                             .when_some(project_paths, |this, paths| {
                                 this.child(
                                     Label::new(paths)
@@ -471,16 +513,16 @@ impl RenderOnce for ThreadItem {
                             .when(has_project_paths && has_worktree, |this| {
                                 this.child(dot_separator())
                             })
-                            .children(worktree_chips)
+                            .children(worktree_labels)
                             .when(
-                                (has_project_paths || has_worktree)
+                                (has_project_name || has_project_paths || has_worktree)
                                     && (has_diff_stats || has_timestamp),
                                 |this| this.child(dot_separator()),
                             )
                             .when(has_diff_stats, |this| {
                                 this.child(
                                     DiffStat::new(diff_stat_id, added_count, removed_count)
-                                        .tooltip("Unreviewed changes"),
+                                        .tooltip("Unreviewed Changes"),
                                 )
                             })
                             .when(has_diff_stats && has_timestamp, |this| {