From b8bfdd13bc217f8570390f9edb30d60afc0ad437 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:30:27 -0500 Subject: [PATCH] Agent: add a collapsed sidebar rail and unified header Replace the floating collapsed-sidebar controls with a 48 px rail. Keep the Maple mark at exactly the expanded wordmark M position and scale by sharing row geometry and SVG viewport sizing, so collapsing removes only the rest of the wordmark. Keep one Toggle sidebar button fixed beside the window controls in both sidebar states. Show New task and a divider before the title when collapsed. Tab and Shift-Tab traverse the controls; Enter and Space activate them without changing unsent composer text. Use the sidebar background across the header in both sidebar states. Round the content panel at its top-left corner while keeping its own background and a square top-right edge. Reuse the merged action icon button helper. Co-Authored-By: Claude Opus 5.5 --- .../app/assets/icons/maple-mark.svg | 1 + apps/maple-agent/app/src/assets.rs | 1 + apps/maple-agent/app/src/ui/chat/composer.rs | 69 +++- apps/maple-agent/app/src/ui/chat/mod.rs | 172 +++++++--- apps/maple-agent/app/src/ui/chat/sidebar.rs | 65 ++-- apps/maple-agent/app/src/ui/chat/tests.rs | 294 +++++++++++++++++- apps/maple-agent/app/src/ui/widgets.rs | 2 +- 7 files changed, 511 insertions(+), 93 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/maple-mark.svg diff --git a/apps/maple-agent/app/assets/icons/maple-mark.svg b/apps/maple-agent/app/assets/icons/maple-mark.svg new file mode 100644 index 000000000..b34a0400d --- /dev/null +++ b/apps/maple-agent/app/assets/icons/maple-mark.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index 0885906a8..bbb74b98c 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -37,6 +37,7 @@ assets!( "icons/loader-circle.svg", "icons/lock.svg", "icons/mic.svg", + "icons/maple-mark.svg", "icons/maple-wordmark.svg", "icons/maximize-2.svg", "icons/minimize-2.svg", diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index cfb173d36..2c52ee0bf 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -110,20 +110,87 @@ impl ChatScreen { titlebar::drag_region( div() .id("chat-header") + .debug_selector(|| "chat-header".to_string()) + .relative() .flex() .items_center() .justify_between() .gap_3() - .h(px(40.)) + .h(super::CHAT_HEADER_HEIGHT) .flex_none() .pl_4() .pr_3() + .bg(gpui::rgb(theme::bg_sidebar())) .when(self.sidebar_collapsed, |row| { row.pl(SIDEBAR_COLLAPSED_INSET) }), ) .child( div() + .flex() + .items_center() + .gap_1() + .when(!self.sidebar_collapsed, |toolbar| { + toolbar + .absolute() + .left( + super::SIDEBAR_RAIL_WIDTH + SIDEBAR_COLLAPSED_INSET + - super::SIDEBAR_WIDTH, + ) + .top((super::CHAT_HEADER_HEIGHT - super::sidebar::TOP_ROW_HEIGHT) / 2.) + }) + .children( + [ + ( + "header-sidebar-toggle", + "panel-left", + "Toggle sidebar", + &super::ToggleSidebar as &dyn gpui::Action, + ChatCommand::ToggleSidebar, + 3, + ), + ( + "header-new-task", + "square-pen", + "New task", + &super::NewTask, + ChatCommand::NewTask, + 4, + ), + ] + .into_iter() + .filter(|(_, _, _, _, command, _)| { + self.sidebar_collapsed || matches!(command, ChatCommand::ToggleSidebar) + }) + .map(|(id, name, label, action, command, tab_index)| { + widgets::icon_button_for_action( + id, + name, + label, + px(16.), + theme::text_secondary(), + action, + ) + .debug_selector(move || id.to_string()) + .tab_index(tab_index) + .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { + cx.stop_propagation(); + }) + .on_key_down(Self::sidebar_button_key_down) + .on_click(cx.listener( + move |this, _event, window, cx| { + this.execute_command(command, window, cx); + }, + )) + }), + ), + ) + .when(self.sidebar_collapsed, |row| { + row.child(div().w(px(1.)).h_4().bg(gpui::rgb(theme::border()))) + }) + .child( + div() + .debug_selector(|| "header-title".to_string()) // Sized by its text, like the chips: nowrap gives it a // real intrinsic width (a clamped, shrinkable title // measured as 0 px and vanished). The cap keeps a long diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 00f54cbcd..376ba4227 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -19,7 +19,7 @@ use maple_agent::agent::{ }; use crate::backend::{AgentBackend, PendingQuestion}; -use crate::ui::icons::{icon, spinner, wordmark}; +use crate::ui::icons::{icon, spinner}; use crate::ui::markdown; use crate::ui::motion; use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; @@ -27,6 +27,7 @@ use crate::ui::rich_text::{self, RenderCtx}; use crate::ui::settings::{OpenSettingsSection, Section}; use crate::ui::text_input::TextInput; use crate::ui::theme; +use crate::ui::titlebar; use crate::ui::widgets; mod cache; @@ -106,9 +107,17 @@ struct QueueEdit { /// Width of the task sidebar. const SIDEBAR_WIDTH: gpui::Pixels = px(300.); -/// Left inset that keeps the header and its menus clear of the floating -/// sidebar toggle while the sidebar is collapsed. -const SIDEBAR_COLLAPSED_INSET: gpui::Pixels = px(220.); +/// Width of the rail that stands in for the sidebar while it is collapsed. +const SIDEBAR_RAIL_WIDTH: gpui::Pixels = px(48.); +/// 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. +const SIDEBAR_COLLAPSED_INSET: gpui::Pixels = if titlebar::TRANSPARENT_TITLEBAR { + px(30.) +} else { + px(16.) +}; /// Reading width shared by the transcript and the composer; they must /// stay in one column. const CONTENT_WIDTH: gpui::Pixels = px(900.); @@ -6516,12 +6525,6 @@ impl Render for ChatScreen { // Whatever the pointer is over, a wheel that nothing there // takes scrolls the transcript. .on_scroll_wheel(cx.listener(Self::scroll_transcript_from_pane)) - .child( - // The header is the window's title bar; a wheel over it - // scrolls nothing. - self.render_header(window, cx) - .on_scroll_wheel(|_, _, cx| cx.stop_propagation()), - ) .child(self.render_transcript(window, cx)) .children(self.render_send_preview()) .when( @@ -6638,6 +6641,7 @@ impl Render for ChatScreen { .min_h_0() .flex() .flex_row() + .when(collapsed, |row| row.child(self.render_sidebar_rail(cx))) .when(!collapsed, |row| { // Cached: the sidebar's subtree is reused until the // sidebar entity is notified, whatever this screen does. @@ -6658,20 +6662,23 @@ impl Render for ChatScreen { .flex_1() .h_full() .min_w_0() - .when(collapsed, |pane| { - pane.child( - div() - .absolute() - .top_2() - .left(crate::ui::titlebar::top_row_inset(px(12.))) - .flex() - .items_center() - .gap_2() - .child(self.render_sidebar_toggle(cx)) - .child(wordmark(px(14.), theme::text_primary())), - ) - }) - .child(main), + .bg(gpui::rgb(theme::bg_sidebar())) + .child( + // The header is the window's title bar; a wheel + // over it scrolls nothing. + self.render_header(window, cx) + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .rounded_tl(theme::RADIUS_LG) + .bg(gpui::rgb(theme::bg_app())) + .child(main), + ), ), ) .when_some(self.lightbox.clone(), |root, image| { @@ -6769,34 +6776,102 @@ impl ChatScreen { ) } - /// Panel icon that hides or shows the sidebar. - fn render_sidebar_toggle(&self, cx: &mut Context) -> gpui::Stateful
{ + /// Tab moves among the rail and header buttons. Space presses the + /// focused button on release, rather than typing into the composer. + fn sidebar_button_key_down( + event: &gpui::KeyDownEvent, + window: &mut Window, + cx: &mut gpui::App, + ) { + if event.keystroke.key == "space" { + cx.stop_propagation(); + } else if event.keystroke.key == "tab" + && event + .keystroke + .modifiers + .is_subset_of(&gpui::Modifiers::shift()) + { + if event.keystroke.modifiers.shift { + window.focus_prev(cx); + } else { + window.focus_next(cx); + } + cx.stop_propagation(); + } + } + + /// The collapsed sidebar: a slim rail in the sidebar's color whose + /// Maple mark shows the sidebar again. + fn render_sidebar_rail(&self, cx: &mut Context) -> gpui::Stateful
{ + let mark_padding = (sidebar::TOP_ROW_HEIGHT - sidebar::WORDMARK_HEIGHT) / 2.; + let rail = titlebar::drag_region( + div() + .id("sidebar-rail") + .debug_selector(|| "sidebar-rail".to_string()) + .relative() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .bg(gpui::rgb(theme::bg_sidebar())), + ) + .child( + div() + .id("show-sidebar") + .debug_selector(|| "show-sidebar".to_string()) + .absolute() + .left(sidebar::WORDMARK_LEFT - mark_padding) + .top(sidebar::wordmark_row_top() - CHAT_HEADER_HEIGHT) + .role(gpui::Role::Button) + .aria_label("Show sidebar") + // The search field's place in the tab order, which the rail + // holds while the sidebar is collapsed. + .tab_index(2) + .flex_none() + .size(sidebar::TOP_ROW_HEIGHT) + .overflow_hidden() + .flex() + .items_center() + .pl(mark_padding) + .rounded(theme::RADIUS_SM) + .focus_visible(widgets::focus_ring) + .hover(|style| { + style + .bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() + }) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) + // Inside a drag region: a press here is a click, not a drag. + .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { + cx.stop_propagation(); + }) + .on_key_down(Self::sidebar_button_key_down) + .tooltip_show_delay(widgets::ICON_TOOLTIP_DELAY) + .tooltip(widgets::tooltip_for_action("Show sidebar", &ToggleSidebar)) + .on_click(cx.listener(|this, _event, window, cx| { + this.execute_command(ChatCommand::ToggleSidebar, window, cx); + })) + .child( + crate::ui::icons::wordmark(sidebar::WORDMARK_HEIGHT, theme::text_primary()) + .path("icons/maple-mark.svg") + .debug_selector(|| "sidebar-rail-mark".to_string()), + ), + ); div() - .id("sidebar-toggle") + .id("sidebar-rail-column") .flex_none() - .size_7() + .w(SIDEBAR_RAIL_WIDTH) + .h_full() .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) - // Inside a drag region: a press here is a click, not a drag. - .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { - cx.stop_propagation(); - }) - .tooltip(widgets::tooltip_for_action( - "Toggle sidebar", - &ToggleSidebar, + .flex_col() + .child(titlebar::drag_region( + div() + .id("sidebar-rail-titlebar") + .h(CHAT_HEADER_HEIGHT) + .flex_none() + .bg(gpui::rgb(theme::bg_sidebar())), )) - .on_click(cx.listener(|this, _event, window, cx| { - this.execute_command(ChatCommand::ToggleSidebar, window, cx); - })) - .child(icon("panel-left", px(16.), theme::text_secondary())) + .child(rail) } /// Hero layout for a task with no messages: display heading, composer, @@ -6871,7 +6946,6 @@ impl ChatScreen { .flex_1() .h_full() .min_w_0() - .child(self.render_header(window, cx)) .child(body) } diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index c1c4b7da0..4990bc75b 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -80,6 +80,17 @@ use crate::ui::theme; use crate::ui::titlebar; use crate::ui::widgets; +/// 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.); +pub(super) const TOP_ROW_HEIGHT: gpui::Pixels = px(28.); +pub(super) const TOP_ROW_GUTTER: gpui::Pixels = px(12.); + +pub(super) fn wordmark_row_top() -> gpui::Pixels { + titlebar::top_row_top(TOP_ROW_GUTTER) + .max(super::CHAT_HEADER_HEIGHT - (TOP_ROW_HEIGHT - WORDMARK_HEIGHT) / 2.) +} + pub(super) fn session_from_summary( summary: &maple_agent::store::TaskSummary, ) -> AgentSessionSummary { @@ -2895,41 +2906,6 @@ impl Sidebar { }) } - /// The collapse button in the top row. - fn render_toggle(&self) -> gpui::Stateful
{ - let chat = self.chat.clone(); - div() - .id("sidebar-toggle") - .flex_none() - .size_7() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) - // Inside a drag region: a press here is a click, not a drag. - .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { - cx.stop_propagation(); - }) - .tooltip(widgets::tooltip_for_action( - "Toggle sidebar", - &super::ToggleSidebar, - )) - .on_click(move |_event, window, cx| { - if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| { - chat.execute_command(ChatCommand::ToggleSidebar, window, cx); - }); - } - }) - .child(icon("panel-left", px(16.), theme::text_secondary())) - } - /// Sidebar footer: just the settings gear. fn render_footer(&self) -> Div { let chat = self.chat.clone(); @@ -3019,14 +2995,23 @@ impl Render for Sidebar { .flex() .items_center() .justify_between() - .pl_4() - .pr_3() + .pl(WORDMARK_LEFT) + .pr(TOP_ROW_GUTTER) // The wordmark row sits under the traffic lights, not // beside them; the space above it is still the bar. - .pt(titlebar::top_row_top(px(12.))) + .pt(wordmark_row_top()) .pb_2() - .child(wordmark(px(16.), theme::text_primary())) - .child(self.render_toggle()), + .child( + div() + .flex_none() + .h(TOP_ROW_HEIGHT) + .flex() + .items_center() + .child( + wordmark(WORDMARK_HEIGHT, theme::text_primary()) + .debug_selector(|| "sidebar-wordmark".to_string()), + ), + ), )) .child( div() diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1f00bfb9c..e4e486fed 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5893,6 +5893,295 @@ mod state_tests { assert!(cx.debug_bounds("permission-mode-toggle").is_none()); } + /// A collapsed sidebar leaves a rail whose Maple mark shows it again; + /// the expanded sidebar replaces the rail. + #[gpui::test] + fn collapsed_sidebar_rail_expands_it(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + this.set_sidebar_collapsed(true, cx); + }); + cx.run_until_parked(); + let mark = cx.debug_bounds("show-sidebar").expect("the rail's mark"); + let rail = cx.debug_bounds("sidebar-rail").expect("the rail"); + let header = cx.debug_bounds("chat-header").expect("the header"); + assert_eq!(rail.top(), header.bottom()); + assert_eq!(rail.left(), px(0.)); + assert_eq!(rail.size.width, SIDEBAR_RAIL_WIDTH); + assert_eq!(rail.bottom(), px(800.)); + assert!(mark.right() <= SIDEBAR_RAIL_WIDTH); + + press(cx, "show-sidebar"); + assert!(!cx.update(|_window, app| chat.read(app).sidebar_collapsed)); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!(cx.debug_bounds("show-sidebar").is_none()); + } + + /// Collapsing removes only the rest of the wordmark: the M keeps the + /// same scale and origin, including device-pixel rounding. + #[gpui::test] + fn collapsed_sidebar_mark_matches_the_wordmark(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let wordmark = cx.debug_bounds("sidebar-wordmark").expect("wordmark"); + let wordmark_svg = include_str!("../../../assets/icons/maple-wordmark.svg"); + let mark_svg = include_str!("../../../assets/icons/maple-mark.svg"); + for svg in [wordmark_svg, mark_svg] { + assert!(svg.contains("viewBox=\"0 0 248 50\"")); + assert!(svg.contains("transform=\"translate(0, 1.2)\"")); + } + let m_path = wordmark_svg + .split("") + .next() + .unwrap(); + assert!(mark_svg.contains(m_path)); + chat.update(cx, |this, cx| this.set_sidebar_collapsed(true, cx)); + cx.run_until_parked(); + let mark = cx.debug_bounds("sidebar-rail-mark").expect("rail mark SVG"); + assert_eq!(mark, wordmark); + + press(cx, "show-sidebar"); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert_eq!(cx.debug_bounds("sidebar-wordmark"), Some(wordmark)); + } + + /// The collapsed header keeps its commands before the title in both + /// pane layouts; expanding keeps only the same fixed toggle. + #[gpui::test] + fn collapsed_sidebar_header_toolbar(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + for has_history in [false, true] { + chat.update(cx, |this, cx| { + this.replace_timeline(if has_history { + vec![user_item("u1", "hi")] + } else { + Vec::new() + }); + this.set_sidebar_collapsed(true, cx); + }); + cx.run_until_parked(); + let toggle = cx.debug_bounds("header-sidebar-toggle").expect("toggle"); + let new_task = cx.debug_bounds("header-new-task").expect("new task"); + let title = cx.debug_bounds("header-title").expect("title"); + let project = cx.debug_bounds("root-picker").expect("project"); + assert_eq!( + cx.debug_bounds("sidebar-rail").unwrap().top(), + cx.debug_bounds("chat-header").unwrap().bottom() + ); + assert_eq!(toggle.left(), SIDEBAR_RAIL_WIDTH + SIDEBAR_COLLAPSED_INSET); + assert!(toggle.right() < new_task.left()); + assert_eq!(new_task.right() + px(25.), title.left()); + assert!(title.right() < project.left()); + assert_eq!(toggle.center().y, title.center().y); + + press(cx, "header-sidebar-toggle"); + assert!(!cx.update(|_, app| chat.read(app).sidebar_collapsed)); + cx.run_until_parked(); + assert_eq!(cx.debug_bounds("header-sidebar-toggle"), Some(toggle)); + assert!(cx.debug_bounds("header-new-task").is_none()); + assert!(cx.debug_bounds("root-picker").is_some()); + assert_eq!( + cx.debug_bounds("header-title").unwrap().left(), + SIDEBAR_WIDTH + px(16.) + ); + } + } + + /// The same header toggle survives either transition and remains a + /// keyboard target when expanded. + #[gpui::test] + fn collapsed_sidebar_toggle_keeps_its_bounds(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let toggle = cx + .debug_bounds("header-sidebar-toggle") + .expect("expanded toggle"); + let composer = cx.update(|_, app| chat.read(app).composer.clone().unwrap()); + let composer_focus = cx.update(|_, app| composer.read(app).focus_handle(app)); + for key in ["enter", "space"] { + composer.update(cx, |input, cx| input.set_text("Unsent draft", cx)); + cx.run_until_parked(); + cx.update(|window, app| window.focus(&composer_focus, app)); + cx.simulate_keystrokes("tab tab"); + assert_eq!( + cx.update(|window, app| window.focused(app).unwrap().tab_index), + 3 + ); + cx.simulate_keystrokes(key); + cx.update(|window, app| { + window.dispatch_event( + gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse(key).unwrap(), + }), + app, + ); + }); + cx.run_until_parked(); + assert!(cx.update(|_, app| chat.read(app).sidebar_collapsed)); + assert_eq!(cx.debug_bounds("header-sidebar-toggle"), Some(toggle)); + assert_eq!( + ["header-sidebar-toggle", "sidebar-toggle"] + .into_iter() + .filter(|selector| cx.debug_bounds(selector).is_some()) + .count(), + 1 + ); + assert_eq!(composer.update(cx, |input, _| input.text()), "Unsent draft"); + press(cx, "header-sidebar-toggle"); + assert!(!cx.update(|_, app| chat.read(app).sidebar_collapsed)); + cx.run_until_parked(); + assert_eq!(cx.debug_bounds("header-sidebar-toggle"), Some(toggle)); + assert_eq!( + ["header-sidebar-toggle", "sidebar-toggle"] + .into_iter() + .filter(|selector| cx.debug_bounds(selector).is_some()) + .count(), + 1 + ); + assert!(cx.debug_bounds("header-new-task").is_none()); + } + // An expanded-header press reaches the toggle rather than the + // sidebar's drag region beneath it. + press(cx, "header-sidebar-toggle"); + assert!(cx.update(|_, app| chat.read(app).sidebar_collapsed)); + } + + /// The header's New task button starts the same draft as the sidebar. + #[gpui::test] + fn collapsed_sidebar_header_starts_a_draft(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary_at("s1", "One", "/work/alpha")]; + this.project_root = Some("/work/alpha".to_string()); + this.replace_timeline(vec![user_item("u1", "hi")]); + this.sync_sidebar(cx); + this.set_sidebar_collapsed(true, cx); + }); + press(cx, "header-new-task"); + chat.update(cx, |this, _| { + assert!(this.sidebar_collapsed); + assert_eq!(this.selected_session, None); + assert_eq!(this.selected_title.as_ref(), "New Task"); + assert!(this.timeline.is_empty()); + assert!(!this.session_setup_pending); + assert_eq!(this.sessions.len(), 1); + assert_eq!(this.project_root.as_deref(), Some("/work/alpha")); + }); + } + + /// Tab reaches both header buttons; Enter and Space activate on release + /// without forwarding a character to the composer. + #[gpui::test] + fn collapsed_sidebar_header_buttons_take_the_keyboard(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary_at("s1", "One", "/work/alpha")]; + this.project_root = Some("/work/alpha".to_string()); + this.sync_sidebar(cx); + }); + let composer = cx.update(|_, app| chat.read(app).composer.clone().unwrap()); + let composer_focus = cx.update(|_, app| composer.read(app).focus_handle(app)); + for (tabs, tab_index, new_task) in [("tab tab", 3, false), ("tab tab tab", 4, true)] { + for key in ["enter", "space"] { + chat.update(cx, |this, cx| { + this.selected_session = Some("s1".to_string()); + this.draft = false; + this.replace_timeline(vec![user_item("u1", "hi")]); + this.sync_sidebar(cx); + this.set_sidebar_collapsed(true, cx); + cx.notify(); + }); + composer.update(cx, |input, cx| input.set_text("Unsent draft", cx)); + cx.run_until_parked(); + cx.update(|window, app| window.focus(&composer_focus, app)); + cx.simulate_keystrokes(tabs); + let button_focus = cx.update(|window, app| window.focused(app).unwrap()); + assert_eq!(button_focus.tab_index, tab_index, "Tab reaches the button"); + cx.simulate_keystrokes("shift-tab"); + assert_eq!( + cx.update(|window, app| window.focused(app).unwrap().tab_index), + tab_index - 1 + ); + cx.simulate_keystrokes("tab"); + + cx.simulate_keystrokes(key); + assert_eq!(composer.update(cx, |input, _| input.text()), "Unsent draft"); + assert_eq!( + cx.update(|window, app| window.focused(app)), + Some(button_focus), + "key-down keeps focus on the button" + ); + cx.update(|window, app| { + let keystroke = gpui::Keystroke::parse(key).unwrap(); + window.dispatch_event( + gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { keystroke }), + app, + ); + }); + cx.run_until_parked(); + chat.update(cx, |this, _| { + assert_eq!( + this.sidebar_collapsed, new_task, + "{key} activates the button" + ); + assert_eq!(this.draft, new_task); + assert_eq!( + this.selected_session.as_deref(), + if new_task { None } else { Some("s1") } + ); + assert_eq!(this.timeline.is_empty(), new_task); + assert_eq!(this.sessions.len(), 1); + assert_eq!(this.project_root.as_deref(), Some("/work/alpha")); + }); + assert_eq!(composer.update(cx, |input, _| input.text()), "Unsent draft"); + } + } + } + + /// Tab reaches the rail's mark, and Enter or Space on it shows the + /// sidebar. + #[gpui::test] + fn collapsed_sidebar_rail_mark_takes_the_keyboard(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let composer = cx.update(|_window, app| chat.read(app).composer.clone().unwrap()); + let composer_focus = cx.update(|_window, app| composer.read(app).focus_handle(app)); + for key in ["enter", "space"] { + chat.update(cx, |this, cx| this.set_sidebar_collapsed(true, cx)); + cx.run_until_parked(); + cx.update(|window, app| window.focus(&composer_focus, app)); + cx.simulate_keystrokes("tab"); + assert!(!cx.update(|window, _| composer_focus.is_focused(window))); + + // A keyboard click lands on the key's release. + cx.simulate_keystrokes(key); + cx.update(|window, app| { + let keystroke = gpui::Keystroke::parse(key).unwrap(); + window.dispatch_event( + gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { keystroke }), + app, + ); + }); + assert!( + !cx.update(|_window, app| chat.read(app).sidebar_collapsed), + "{key} on the mark shows the sidebar" + ); + } + } + /// Issue #997: a second press on the button that opened a menu closes /// it. Before, the menu's outside-press handler closed it on the way /// down and the button's click opened it again. @@ -7732,7 +8021,8 @@ mod state_tests { wheel_at(cx, 600., 300., wheel_pixels(400.)); for (place, x, y, moves) in [ ("the transcript", 600., 300., 100.), - ("the left margin", 40., 300., 100.), + // Past the collapsed rail. + ("the left margin", 80., 300., 100.), ("the gutter beside the text", 160., 300., 100.), ("the scrollbar strip", 1195., 300., 100.), ("the margin beside the composer", 1150., 790., 100.), @@ -7940,7 +8230,7 @@ mod state_tests { Some(before), "a wheel over the card leaves the transcript alone" ); - wheel_at(cx, 40., y, wheel_pixels(100.)); + wheel_at(cx, 80., y, wheel_pixels(100.)); assert_eq!( row_top(&chat, cx, ix).map(|after| after - before), Some(px(100.)), diff --git a/apps/maple-agent/app/src/ui/widgets.rs b/apps/maple-agent/app/src/ui/widgets.rs index 57342d9c6..76cbca4e0 100644 --- a/apps/maple-agent/app/src/ui/widgets.rs +++ b/apps/maple-agent/app/src/ui/widgets.rs @@ -24,7 +24,7 @@ use super::theme; /// How long an icon-only control waits before showing its tooltip. Icons /// are their own label, so the hint should come quickly. -const ICON_TOOLTIP_DELAY: Duration = Duration::from_millis(300); +pub const ICON_TOOLTIP_DELAY: Duration = Duration::from_millis(300); fn accent() -> Hsla { gpui::rgb(theme::accent()).into()