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| {