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()