diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 1072395c8..da5e5eba7 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -132,9 +132,9 @@ impl SidebarTransition { /// Height of the task header and the clear strip above the collapsed rail. const CHAT_HEADER_HEIGHT: gpui::Pixels = px(40.); /// Left inset for the header beside the rail: the traffic lights reach -/// 30 px past it when the bar is transparent. +/// 30 px past it, followed by an 8 px gap when the bar is transparent. const SIDEBAR_COLLAPSED_INSET: gpui::Pixels = if titlebar::TRANSPARENT_TITLEBAR { - px(30.) + px(38.) } else { px(16.) }; @@ -7023,7 +7023,7 @@ impl ChatScreen { this.execute_command(ChatCommand::ToggleSidebar, window, cx); })) .child( - crate::ui::icons::wordmark(sidebar::WORDMARK_HEIGHT, theme::text_primary()) + crate::ui::icons::wordmark(sidebar::WORDMARK_HEIGHT, theme::text_sidebar()) .path("icons/maple-mark.svg") .debug_selector(|| "sidebar-rail-mark".to_string()), ), @@ -7263,12 +7263,10 @@ fn head_change_event(event: ¬ify::Event) -> bool { .any(|path| path.file_name().is_some_and(|name| name == "HEAD")) } -/// Last path component of a project root, for chips and the sidebar. -/// Upper-case section heading in the sidebar. +/// Section heading in the sidebar. fn section_label(text: &'static str) -> Div { div() - .text_xs() - .font_weight(gpui::FontWeight::SEMIBOLD) + .text_sm() .text_color(gpui::rgb(theme::text_muted())) .child(text) } diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index e753d5508..2e620052a 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -85,6 +85,9 @@ use crate::ui::widgets; const SEARCH_ROW_RADIUS: gpui::Pixels = px(6.); +/// Corner radius of a sidebar row's hover and selected highlight. +const ROW_RADIUS: gpui::Pixels = px(6.); + /// Geometry shared by the expanded wordmark row and the collapsed mark. pub(super) const WORDMARK_LEFT: gpui::Pixels = px(16.); pub(super) const WORDMARK_HEIGHT: gpui::Pixels = px(16.); @@ -357,9 +360,9 @@ pub(super) enum SidebarSection { impl SidebarSection { pub(super) fn label(self) -> &'static str { match self { - Self::Pinned => "PINNED", - Self::Active => "ACTIVE", - Self::Settled => "SETTLED", + Self::Pinned => "Pinned", + Self::Active => "Active", + Self::Settled => "Settled", } } @@ -420,6 +423,7 @@ pub(super) struct Sidebar { // The list and its sections. list: gpui::ListState, entries: Vec, + heading_gaps: Vec, rows: Vec, pinned_rows: Vec, active_rows: Vec, @@ -684,6 +688,7 @@ impl Sidebar { vim_active: false, list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)), entries: Vec::new(), + heading_gaps: Vec::new(), rows: Vec::new(), pinned_rows: Vec::new(), active_rows: Vec::new(), @@ -1450,6 +1455,20 @@ impl Sidebar { if self.computers.len() > 1 && self.project_filter.is_none() { entries = self.computer_entries(); } + let heading_gaps = entries + .iter() + .enumerate() + .map(|(ix, entry)| { + matches!( + entry, + SidebarEntry::SectionLabel(_) | SidebarEntry::ArchivedHeader + ) && !matches!( + ix.checked_sub(1).and_then(|ix| entries.get(ix)), + Some(SidebarEntry::SectionLabel(_) | SidebarEntry::ArchivedHeader) + ) + }) + .collect(); + let old_gaps = std::mem::replace(&mut self.heading_gaps, heading_gaps); let old = std::mem::replace(&mut self.entries, entries); if self.application_vim_enabled { self.rebuild_vim_targets(); @@ -1476,6 +1495,14 @@ impl Sidebar { self.entries.len() - prefix - suffix, ); } + // A retained heading can gain or lose its gap when its predecessor + // changes. Invalidate its cached height even when it is off screen. + if suffix > 0 { + let row = self.entries.len() - suffix; + if self.heading_gaps[row] != old_gaps[old.len() - suffix] { + self.list.remeasure_items(row..row + 1); + } + } if self.application_vim_enabled { self.vim_reconcile(); } @@ -2388,6 +2415,7 @@ impl Sidebar { ) -> AnyElement { let selected = self.selected.as_deref(); let chat = self.chat.clone(); + let heading_gap = self.heading_gaps.get(ix).copied().unwrap_or(false); let entry = match self.entries.get(ix).copied() { Some( entry @ (SidebarEntry::ComputerProject(_) @@ -2416,7 +2444,7 @@ impl Sidebar { "New Task", ChatCommand::NewTask, &super::NewTask, - theme::accent(), + theme::text_sidebar(), ) }; div() @@ -2476,15 +2504,16 @@ impl Sidebar { let expanded = self.section_expanded(section); div() .id(gpui::SharedString::from(section.element_id())) - .mt_4() - .w_full() + .w_auto() + .mx_2() + .my(px(1.)) + .when(heading_gap, |row| row.mt_3()) .flex() .items_center() - .gap_1p5() - .mb_1() - .px_4() + .gap_1() + .px_2() .py_1() - .rounded(theme::RADIUS_SM) + .rounded(ROW_RADIUS) .hover(|style| { style .bg(gpui::rgb(theme::bg_sidebar_row_hover())) @@ -2493,6 +2522,7 @@ impl Sidebar { .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_section_expanded(section, cx); })) + .child(section_label(section.label())) .child(icon( if expanded { "chevron-down" @@ -2502,7 +2532,6 @@ impl Sidebar { px(14.), theme::text_secondary(), )) - .child(section_label(section.label())) .into_any_element() } Some(SidebarEntry::Empty(reason)) => { @@ -2568,15 +2597,16 @@ impl Sidebar { .role(gpui::Role::Button) .aria_label(format!("Archived tasks {count_label}")) .aria_expanded(expanded) - .mt_5() - .w_full() + .w_auto() + .mx_2() + .my(px(1.)) + .when(heading_gap, |row| row.mt_3()) .flex() .items_center() - .gap_1p5() - .mb_1() - .px_4() + .gap_1() + .px_2() .py_1() - .rounded(theme::RADIUS_SM) + .rounded(ROW_RADIUS) .hover(|style| { style .bg(gpui::rgb(theme::bg_sidebar_row_hover())) @@ -2585,6 +2615,13 @@ impl Sidebar { .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_archived_visibility(cx); })) + .child(section_label("Archived")) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(count_label), + ) .child(icon( if expanded { "chevron-down" @@ -2594,13 +2631,6 @@ impl Sidebar { px(14.), theme::text_secondary(), )) - .child(section_label("ARCHIVED")) - .child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(count_label), - ) .into_any_element() } None => div().into_any_element(), @@ -2657,15 +2687,16 @@ impl Sidebar { .flex() .items_center() .gap_1p5() - .w_full() - .pl_4() + .w_auto() + .mx_2() + .my(px(1.)) + .pl_2() // Room for the new-project button, which sits over this end. - .pr(px(46.)) + .pr(px(38.)) .py_1() - .rounded(theme::RADIUS_SM) + .rounded(ROW_RADIUS) .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::text_primary())) + .text_color(gpui::rgb(theme::text_sidebar())) .hover(|style| { style .bg(gpui::rgb(theme::bg_sidebar_row_hover())) @@ -2937,9 +2968,16 @@ impl Sidebar { let pin_id = row.pin_id.clone(); let rename_field = self.task_rename_field(&row.id, cx); let renaming = rename_field.is_some(); + let hover_bg = if is_selected { + theme::bg_sidebar_row_selected_hover() + } else { + theme::bg_sidebar_row_hover() + }; div() .relative() - .w_full() + .w_auto() + .mx_2() + .my(px(1.)) .id(row.element_id.clone()) .role(gpui::Role::ListBoxOption) .accessibility_id(row.id.to_string()) @@ -2951,25 +2989,20 @@ impl Sidebar { .flex() .items_center() .gap_1p5() - .when(archived, |row| row.pl_6()) - .when(!archived, |row| row.pl_8()) - .pr_6() + .when(archived, |row| row.pl_4()) + .when(!archived, |row| row.pl_6()) + .pr_4() .py_1() - .rounded(theme::RADIUS_MD) + .rounded(ROW_RADIUS) .text_sm() .when(is_selected, |row| { row.bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .font_weight(gpui::FontWeight::MEDIUM) }) - .text_color(gpui::rgb(theme::text_primary())) + .text_color(gpui::rgb(theme::text_sidebar())) // A row being renamed is a text field: no hover highlight and // no hover actions over it, so the edit is plain to see. .when(!renaming, |el| { - el.hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) + el.hover(|style| style.bg(gpui::rgb(hover_bg)).cursor_pointer()) }) .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); @@ -3035,16 +3068,14 @@ impl Sidebar { .bottom_0() .when(is_pinned, |overlay| overlay.right(px(26.))) .when(!is_pinned, |overlay| { - overlay.right_0().rounded_r(theme::RADIUS_MD) + overlay.right_0().rounded_r(ROW_RADIUS) }) .flex() .items_center() .gap_1p5() .pl_2() - .pr_6() - .group_hover(row.group.clone(), |style| { - style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }) + .pr_4() + .group_hover(row.group.clone(), |style| style.bg(gpui::rgb(hover_bg))) .child(self.task_menu_trigger(row, task, window, cx)) .child(row_action( row.rename_id.clone(), @@ -3139,7 +3170,7 @@ impl Render for Sidebar { .flex() .items_center() .child( - wordmark(WORDMARK_HEIGHT, theme::text_primary()) + wordmark(WORDMARK_HEIGHT, theme::text_sidebar()) .debug_selector(|| "sidebar-wordmark".to_string()), ), ), @@ -3302,6 +3333,105 @@ fn remote_root_display_name(root: &str) -> String { mod tests { use super::{remote_root_display_name, root_display_name}; + #[gpui::test] + fn sidebar_remeasures_an_offscreen_heading_when_its_gap_changes(cx: &mut gpui::TestAppContext) { + use super::*; + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let chat = cx.new(|cx| ChatScreen::new_without_start(backend, "user".into(), cx)); + let sidebar = chat.update(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.application_vim_enabled = true; + sidebar.list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(0.)); + let sessions = (0..102) + .map(|ix| AgentSessionSummary { + id: format!("s{ix}"), + title: format!("Task {ix}"), + project_root: "/tmp/proj".into(), + created_ms: 0, + updated_ms: 0, + message_count: 1, + model: None, + web_enabled: true, + state: if ix == 101 { + AgentTaskState::Settled + } else { + AgentTaskState::Active + }, + acp: false, + }) + .collect(); + sidebar.pinned_tasks = (0..100).map(|ix| format!("s{ix}")).collect(); + sidebar.set_sessions(sessions, cx); + }); + let list = sidebar.update(cx, |sidebar, _| sidebar.list.clone()); + struct Host(Entity); + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(300.)) + .h(px(400.)) + .flex() + .flex_col() + .child(self.0.clone()) + } + } + let (_host, cx) = cx.add_window_view(|_, _| Host(sidebar.clone())); + cx.simulate_resize(gpui::size(px(300.), px(400.))); + let settled = |cx: &mut gpui::VisualTestContext| { + sidebar.update(cx, |sidebar, _| { + sidebar + .entries + .iter() + .position(|entry| *entry == SidebarEntry::SectionLabel(SidebarSection::Settled)) + .unwrap() + }) + }; + let row = settled(cx); + sidebar.update(cx, |sidebar, cx| { + sidebar.list.scroll_to(gpui::ListOffset { + item_ix: row, + offset_in_item: px(0.), + }); + cx.notify(); + }); + cx.run_until_parked(); + let height = list.bounds_for_item(row).unwrap().size.height; + for expanded in [false, true] { + sidebar.update(cx, |sidebar, cx| { + sidebar.list.scroll_to(gpui::ListOffset::default()); + cx.notify(); + }); + cx.run_until_parked(); + let row = settled(cx); + assert!(list.item_is_below_viewport(row).unwrap()); + assert!(list.bounds_for_item(row).is_some()); + sidebar.update(cx, |sidebar, cx| { + sidebar.toggle_section_expanded(SidebarSection::Active, cx); + }); + let row = settled(cx); + assert!( + list.bounds_for_item(row).is_none(), + "discard the off-screen heading's old measurement" + ); + sidebar.update(cx, |sidebar, cx| { + sidebar.list.scroll_to(gpui::ListOffset { + item_ix: row, + offset_in_item: px(0.), + }); + cx.notify(); + }); + cx.run_until_parked(); + let measured = list.bounds_for_item(row).unwrap().size.height; + if expanded { + assert_eq!(measured, height); + } else { + assert!(measured < height, "adjacent headings lose the section gap"); + } + } + } + #[test] fn remote_project_labels_use_the_owners_path_syntax() { assert_eq!(remote_root_display_name(r"C:\Users\Alex\Maple"), "Maple"); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs index 26e7c5cb0..7e0077475 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1183,6 +1183,11 @@ impl Sidebar { selected: bool, cx: &mut Context, ) -> AnyElement { + let hover_bg = if selected { + theme::bg_sidebar_row_selected_hover() + } else { + theme::bg_sidebar_row_hover() + }; match entry { SidebarEntry::ComputerMore(index) => { let action = self.group_pages.more[index].clone(); @@ -1192,8 +1197,11 @@ impl Sidebar { .id(SharedString::from(format!("computer-page-{index}"))) .role(gpui::Role::Button) .aria_label(label.clone()) - .px_8() + .mx_2() + .my(px(1.)) + .px_6() .py_2() + .rounded(ROW_RADIUS) .text_xs() .text_color(gpui::rgb(theme::text_secondary())) .when(selected, |row| { @@ -1201,10 +1209,7 @@ impl Sidebar { .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) .when(!loading, |row| { - row.hover(|s| { - s.cursor_pointer() - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }) + row.hover(|s| s.cursor_pointer().bg(gpui::rgb(hover_bg))) }) .on_click(cx.listener(move |this, _, _, cx| { if !loading { @@ -1221,9 +1226,12 @@ impl Sidebar { .id(project.id.clone()) .role(gpui::Role::Button) .aria_label(project.label.clone()) + .mx_2() + .my(px(1.)) .mt_3() - .px_4() + .px_2() .py_1p5() + .rounded(ROW_RADIUS) .flex() .gap_2() .items_center() @@ -1233,10 +1241,7 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) + .hover(|s| s.bg(gpui::rgb(hover_bg)).cursor_pointer()) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) })) @@ -1262,23 +1267,22 @@ impl Sidebar { .role(gpui::Role::ListBoxOption) .aria_label(task.title.clone()) .aria_selected(false) - .pl_8() - .pr_6() + .mx_2() + .my(px(1.)) + .pl_6() + .pr_4() .py_1() .flex() .gap_2() .items_center() - .rounded(theme::RADIUS_MD) + .rounded(ROW_RADIUS) .text_sm() - .text_color(gpui::rgb(theme::text_primary())) + .text_color(gpui::rgb(theme::text_sidebar())) .when(selected, |row| { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) + .hover(|s| s.bg(gpui::rgb(hover_bg)).cursor_pointer()) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) })) @@ -1308,18 +1312,18 @@ impl Sidebar { .id(SharedString::from(format!("computer-{index}"))) .role(gpui::Role::Button) .aria_label(computer.label.clone()) + .mx_2() + .my(px(1.)) .mt_3() - .px_4() + .px_2() .py_2() + .rounded(ROW_RADIUS) .flex() .flex_col() .gap_1() .text_xs() .text_color(gpui::rgb(theme::text_secondary())) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) + .hover(|s| s.bg(gpui::rgb(hover_bg)).cursor_pointer()) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) })) diff --git a/apps/maple-agent/app/src/ui/theme.rs b/apps/maple-agent/app/src/ui/theme.rs index 0551fef9f..81f367c7c 100644 --- a/apps/maple-agent/app/src/ui/theme.rs +++ b/apps/maple-agent/app/src/ui/theme.rs @@ -203,6 +203,8 @@ tokens! { // Dark body is Pebble 200 (~12:1 on the well), not Pebble 50, so // Regular and SemiBold stay distinct. Headings sit one step brighter. text_primary: 0xd1d2dc, 0x171717; + /// Sidebar text stays softer than body text in both themes. + text_sidebar: 0xbcbdc8, 0x45454b; text_heading: 0xe8e8ed, 0x171717; text_secondary: 0xbabccb, 0x525252; /// Captions. Light sits between neutral-500 and 600: neutral-400 was @@ -242,6 +244,7 @@ tokens! { bg_sidebar_chrome: 0x1e1e24, 0xffffff; bg_sidebar_row_hover: 0x32323a, 0xe8e8ed; bg_sidebar_row_selected: 0x3a3a44, 0xd1d2dc; + bg_sidebar_row_selected_hover: 0x43434e, 0xc5c6d1; /// Display headings (Array face): pebble-300 on dark, pebble-800 on /// light, as the brand kit sets its section titles.