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