From 9526861beed598797a96a29d83b4c79b0d888973 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 31 Mar 2026 07:21:10 -0300 Subject: [PATCH] sidebar: Adjust thread switcher modal (#52788) 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 --- 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(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index b6d6ca98cf8b61cb7cba95cd204f4a8a429cb538..b5915af91279e158242755d0fb3f7ce4b3df6233 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -2482,13 +2482,17 @@ impl Sidebar { } fn mru_threads_for_switcher(&self, _cx: &App) -> Vec { + let mut current_header_label: Option = None; let mut current_header_workspace: Option> = None; let mut entries: Vec = 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, diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index a7f6584896c197374d02c7180f5d7bf19844daa7..86e2aeba38b9ee18a8f56597abc0d62f5741b714 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/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, - pub worktree_name: Option, + pub project_name: Option, + pub worktrees: Vec, 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) -> 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() })) } } diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index aebfbffce6926741c7ec1faa393750b3a1b35ebd..e03dce5fe2e5ce9e52ca01da72e69605abfff765 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/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, removed: Option, project_paths: Option>, + project_name: Option, worktrees: Vec, on_click: Option>, on_hover: Box, action_slot: Option, tooltip: Option AnyView + 'static>>, + base_bg: Option, } 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) -> Self { + self.project_name = Some(name.into()); + self + } + pub fn worktrees(mut self, worktrees: Vec) -> 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 = Vec::new(); - let mut worktree_chips: Vec = Vec::new(); + let mut worktree_labels: Vec = 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| {