From 1bc0e233bb96238eb4a44372b26885c8781835ad Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:05:25 -0300 Subject: [PATCH] sidebar: Add adjustments to project header (#53891) - Change design a bit so that the separation between projects is clearer - Add ability to cmd-click to focus most recent workspace - Make hover and collapse/expand interactions unconditional, enabled by adding a "No threads yet" empty state - Improved keyboard nav by making cmd-n create a new thread in the currently focused group https://github.com/user-attachments/assets/e9cde153-d3f1-4945-9e45-db1597637a44 Release Notes: - Agent: Improved the threads sidebar header by making the separation between projects more distinct. --- crates/sidebar/src/sidebar.rs | 343 ++++++++++++++++++---------------- 1 file changed, 185 insertions(+), 158 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 2f263d260f2c829653a342dab219865d869d1da0..283f8edc4f0c9fe555bb040d92c600e34bd2a5c3 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -16,9 +16,9 @@ use agent_ui::{ use chrono::{DateTime, Utc}; use editor::Editor; use gpui::{ - Action as _, AnyElement, App, Context, DismissEvent, Entity, EntityId, FocusHandle, Focusable, - KeyContext, ListState, Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, - linear_color_stop, linear_gradient, list, prelude::*, px, + Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle, + Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, WeakEntity, + Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, }; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, @@ -40,7 +40,7 @@ use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, - Tooltip, WithScrollbar, prelude::*, + Tooltip, WithScrollbar, prelude::*, render_modifiers, }; use util::ResultExt as _; use util::path_list::PathList; @@ -1604,10 +1604,9 @@ impl Sidebar { let id_prefix = if is_sticky { "sticky-" } else { "" }; let id = SharedString::from(format!("{id_prefix}project-header-{ix}")); - let disclosure_id = SharedString::from(format!("disclosure-{ix}")); let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}")); - let is_collapsed = !has_threads || self.is_group_collapsed(key, cx); + let is_collapsed = self.is_group_collapsed(key, cx); let (disclosure_icon, disclosure_tooltip) = if is_collapsed { (IconName::ChevronRight, "Expand Project") } else { @@ -1639,11 +1638,10 @@ impl Sidebar { .element_active .blend(color.element_background.opacity(0.2)); let hover_solid = base_bg.blend(hover_base); - let real_hover_color = if is_active { base_bg } else { hover_solid }; let group_name_for_gradient = group_name.clone(); let gradient_overlay = move || { - GradientFade::new(base_bg, real_hover_color, real_hover_color) + GradientFade::new(base_bg, hover_solid, hover_solid) .width(px(64.0)) .right(px(-2.0)) .gradient_stop(0.75) @@ -1652,13 +1650,14 @@ impl Sidebar { let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix); - h_flex() + let header = h_flex() .id(id) .group(&group_name) - .h(Tab::content_height(cx)) + .cursor_pointer() .relative() + .h(Tab::content_height(cx)) .w_full() - .pl(px(5.)) + .pl_2() .pr_1p5() .justify_between() .border_1() @@ -1669,30 +1668,13 @@ impl Sidebar { this.border_color(gpui::transparent_black()) } }) + .hover(|s| s.bg(hover_solid)) .child( h_flex() .relative() .min_w_0() .w_full() - .gap(px(5.)) - .child( - IconButton::new(disclosure_id, disclosure_icon) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(if has_threads { - Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)) - } else { - Color::Custom(cx.theme().colors().icon_disabled) - }) - .when(has_threads, |this| { - this.tooltip(Tooltip::text(disclosure_tooltip)).on_click( - cx.listener(move |this, _, window, cx| { - this.selection = None; - this.toggle_collapse(&key_for_toggle, window, cx); - }), - ) - }), - ) + .gap_1() .child(label) .when_some( self.render_remote_project_icon(ix, host.as_ref()), @@ -1726,7 +1708,16 @@ impl Sidebar { .tooltip(Tooltip::text(tooltip_text)), ) }) - }), + }) + .child( + div() + .when(!is_focused, |this| this.visible_on_hover(&group_name)) + .child( + Icon::new(disclosure_icon) + .size(IconSize::Small) + .color(Color::Muted), + ), + ), ) .child(gradient_overlay()) .child( @@ -1738,31 +1729,6 @@ impl Sidebar { .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx)) - .when(has_threads && view_more_expanded && !is_collapsed, |this| { - this.child( - IconButton::new( - SharedString::from(format!( - "{id_prefix}project-header-collapse-{ix}", - )), - IconName::ListCollapse, - ) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Collapse Displayed Threads")) - .on_click(cx.listener({ - let key_for_collapse = key_for_collapse.clone(); - move |this, _, _window, cx| { - this.selection = None; - this.set_group_visible_thread_count( - &key_for_collapse, - None, - cx, - ); - this.update_entries(cx); - } - })), - ) - }) .child({ let key = key.clone(); let focus_handle = self.focus_handle.clone(); @@ -1786,37 +1752,103 @@ impl Sidebar { move |this, _, window, cx| { this.set_group_expanded(&key, true, cx); this.selection = None; - let workspace = this.multi_workspace.upgrade().and_then(|mw| { - let mw = mw.read(cx); - let active = mw.workspace().clone(); - let active_key = active.read(cx).project_group_key(cx); - if active_key == key { - Some(active) - } else { - mw.workspace_for_paths( - key.path_list(), - key.host().as_ref(), - cx, - ) - } - }); - if let Some(workspace) = workspace { + if let Some(workspace) = this.workspace_for_group(&key, cx) { this.create_new_thread(&workspace, window, cx); } else { this.open_workspace_and_create_draft(&key, window, cx); } }, )) - }), + }) + .when(has_threads && view_more_expanded && !is_collapsed, |this| { + this.child( + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-collapse-{ix}", + )), + IconName::ListCollapse, + ) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Show Fewer Threads")) + .on_click(cx.listener({ + let key_for_collapse = key_for_collapse.clone(); + move |this, _, _window, cx| { + this.selection = None; + this.set_group_visible_thread_count( + &key_for_collapse, + None, + cx, + ); + this.update_entries(cx); + } + })), + ) + }) + .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx)), ) - .cursor_pointer() - .when(!is_active, |this| this.hover(|s| s.bg(hover_solid))) - .tooltip(Tooltip::text(disclosure_tooltip)) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.toggle_collapse(&key_for_collapse, window, cx); + .tooltip(Tooltip::element({ + move |_, cx| { + v_flex() + .gap_1() + .child(Label::new(disclosure_tooltip)) + .child( + h_flex() + .pt_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(h_flex().flex_shrink_0().children(render_modifiers( + &Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(TextSize::Default.rems(cx).into()), + false, + ))) + .child( + Label::new("-click to activate most recent workspace") + .color(Color::Muted), + ), + ) + .into_any_element() + } })) - .into_any_element() + .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { + if event.modifiers().platform { + let key = key_for_toggle.clone(); + if let Some(workspace) = this.workspace_for_group(&key, cx) { + this.activate_workspace(&workspace, window, cx); + } else { + this.open_workspace_for_group(&key, window, cx); + } + this.selection = None; + this.active_entry = None; + } else { + this.toggle_collapse(&key_for_toggle, window, cx); + } + })); + + if !is_collapsed && !has_threads { + v_flex() + .w_full() + .child(header) + .child( + h_flex() + .px_2() + .pt_1() + .pb_2() + .gap(px(7.)) + .child(Icon::new(IconName::Circle).size(IconSize::Small).color( + Color::Custom(cx.theme().colors().icon_placeholder.opacity(0.1)), + )) + .child( + Label::new("No threads yet") + .size(LabelSize::Small) + .color(Color::Placeholder), + ), + ) + .into_any_element() + } else { + header.into_any_element() + } } fn render_project_header_ellipsis_menu( @@ -1831,6 +1863,15 @@ impl Sidebar { let project_group_key = project_group_key.clone(); PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}")) + .trigger( + IconButton::new( + SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")), + IconName::Ellipsis, + ) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Project Menu")), + ) .on_open(Rc::new({ let this = this.clone(); move |_window, cx| { @@ -1962,14 +2003,6 @@ impl Sidebar { Some(menu) }) - .trigger( - IconButton::new( - SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")), - IconName::Ellipsis, - ) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .icon_size(IconSize::Small), - ) .anchor(gpui::Corner::TopRight) .offset(gpui::Point { x: px(0.), @@ -2023,8 +2056,8 @@ impl Sidebar { *has_running_threads, *waiting_thread_count, *is_active, - *has_threads, is_selected, + *has_threads, cx, ); @@ -2240,13 +2273,9 @@ impl Sidebar { }; match entry { - ListEntry::ProjectHeader { - key, has_threads, .. - } => { - if *has_threads { - let key = key.clone(); - self.toggle_collapse(&key, window, cx); - } + ListEntry::ProjectHeader { key, .. } => { + let key = key.clone(); + self.toggle_collapse(&key, window, cx); } ListEntry::Thread(thread) => { let metadata = thread.metadata.clone(); @@ -2766,19 +2795,15 @@ impl Sidebar { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { - Some(ListEntry::ProjectHeader { - key, has_threads, .. - }) => { - if *has_threads { - let key = key.clone(); - if self.is_group_collapsed(&key, cx) { - self.set_group_expanded(&key, true, cx); - self.update_entries(cx); - } else if ix + 1 < self.contents.entries.len() { - self.selection = Some(ix + 1); - self.list_state.scroll_to_reveal_item(ix + 1); - cx.notify(); - } + Some(ListEntry::ProjectHeader { key, .. }) => { + let key = key.clone(); + if self.is_group_collapsed(&key, cx) { + self.set_group_expanded(&key, true, cx); + self.update_entries(cx); + } else if ix + 1 < self.contents.entries.len() { + self.selection = Some(ix + 1); + self.list_state.scroll_to_reveal_item(ix + 1); + cx.notify(); } } _ => {} @@ -2794,15 +2819,11 @@ impl Sidebar { let Some(ix) = self.selection else { return }; match self.contents.entries.get(ix) { - Some(ListEntry::ProjectHeader { - key, has_threads, .. - }) => { - if *has_threads { - let key = key.clone(); - if !self.is_group_collapsed(&key, cx) { - self.set_group_expanded(&key, false, cx); - self.update_entries(cx); - } + Some(ListEntry::ProjectHeader { key, .. }) => { + let key = key.clone(); + if !self.is_group_collapsed(&key, cx) { + self.set_group_expanded(&key, false, cx); + self.update_entries(cx); } } Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => { @@ -2842,20 +2863,16 @@ impl Sidebar { }; if let Some(header_ix) = header_ix { - if let Some(ListEntry::ProjectHeader { - key, has_threads, .. - }) = self.contents.entries.get(header_ix) + if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix) { - if *has_threads { - let key = key.clone(); - if self.is_group_collapsed(&key, cx) { - self.set_group_expanded(&key, true, cx); - } else { - self.selection = Some(header_ix); - self.set_group_expanded(&key, false, cx); - } - self.update_entries(cx); + let key = key.clone(); + if self.is_group_collapsed(&key, cx) { + self.set_group_expanded(&key, true, cx); + } else { + self.selection = Some(header_ix); + self.set_group_expanded(&key, false, cx); } + self.update_entries(cx); } } } @@ -4025,22 +4042,17 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { - // If there is a keyboard selection, walk backwards through - // `project_header_indices` to find the header that owns the selected - // row. Otherwise fall back to the active workspace. - // Always use the currently active workspace so that drafts - // are created in the linked worktree the user is focused on, - // not the main worktree resolved from the project header. - let workspace = self - .multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().clone()); - - let Some(workspace) = workspace else { - return; - }; - - self.create_new_thread(&workspace, window, cx); + if let Some(key) = self.selected_group_key() { + self.set_group_expanded(&key, true, cx); + self.selection = None; + if let Some(workspace) = self.workspace_for_group(&key, cx) { + self.create_new_thread(&workspace, window, cx); + } else { + self.open_workspace_and_create_draft(&key, window, cx); + } + } else if let Some(workspace) = self.active_workspace(cx) { + self.create_new_thread(&workspace, window, cx); + } } fn create_new_thread( @@ -4158,6 +4170,34 @@ impl Sidebar { Self::truncate_draft_label(&raw) } + fn selected_group_key(&self) -> Option { + let ix = self.selection?; + match self.contents.entries.get(ix) { + Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), + Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => { + (0..ix) + .rev() + .find_map(|i| match self.contents.entries.get(i) { + Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), + _ => None, + }) + } + _ => None, + } + } + + fn workspace_for_group(&self, key: &ProjectGroupKey, cx: &App) -> Option> { + let mw = self.multi_workspace.upgrade()?; + let mw = mw.read(cx); + let active = mw.workspace().clone(); + let active_key = active.read(cx).project_group_key(cx); + if active_key == *key { + Some(active) + } else { + mw.workspace_for_paths(key.path_list(), key.host().as_ref(), cx) + } + } + fn active_project_group_key(&self, cx: &App) -> Option { let multi_workspace = self.multi_workspace.upgrade()?; let multi_workspace = multi_workspace.read(cx); @@ -4364,18 +4404,6 @@ impl Sidebar { self.collapse_thread_group(&active_key, cx); } - fn on_new_thread( - &mut self, - _: &workspace::NewThread, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.active_workspace(cx) else { - return; - }; - self.create_new_thread(&workspace, window, cx); - } - fn render_no_results(&self, cx: &mut Context) -> impl IntoElement { let has_query = self.has_filter_query(cx); let message = if has_query { @@ -4922,7 +4950,6 @@ impl Render for Sidebar { .on_action(cx.listener(Self::on_previous_thread)) .on_action(cx.listener(Self::on_show_more_threads)) .on_action(cx.listener(Self::on_show_fewer_threads)) - .on_action(cx.listener(Self::on_new_thread)) .on_action(cx.listener(|this, _: &OpenRecent, window, cx| { this.recent_projects_popover_handle.toggle(window, cx); }))