From eeb75093b7979a553c6637000d5d4c61967d1423 Mon Sep 17 00:00:00 2001 From: marks Date: Sat, 24 Oct 2026 00:09:20 -0500 Subject: [PATCH] Agent: animate sidebar transitions and keep Settings visible --- apps/maple-agent/app/src/ui/chat/composer.rs | 51 ++++--- apps/maple-agent/app/src/ui/chat/mod.rs | 147 +++++++++++++++++-- apps/maple-agent/app/src/ui/chat/sidebar.rs | 35 +---- apps/maple-agent/app/src/ui/chat/tests.rs | 9 ++ 4 files changed, 177 insertions(+), 65 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index eee36e85d..7a1a3002a 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -103,10 +103,22 @@ fn header_project_paths(recent_roots: &[RecentProjectRoot]) -> Vec { impl ChatScreen { pub(super) fn render_header( &self, + sidebar_expansion: f32, window: &mut Window, cx: &mut Context, ) -> gpui::Stateful
{ let title = self.selected_title.clone(); + let collapsed_progress = 1. - sidebar_expansion; + let sidebar_width = super::SIDEBAR_RAIL_WIDTH + + (super::SIDEBAR_WIDTH - super::SIDEBAR_RAIL_WIDTH) * sidebar_expansion; + // Compensate for the moving pane so the toggle stays beside the + // window controls throughout the transition. + let toolbar_left = super::SIDEBAR_RAIL_WIDTH + SIDEBAR_COLLAPSED_INSET - sidebar_width; + let toolbar_width = window.rem_size() * 5.75; + let header_gap = window.rem_size() * 0.75; + let expanded_inset = window.rem_size(); + let collapsed_inset = SIDEBAR_COLLAPSED_INSET + toolbar_width + header_gap * 2. + px(1.); + let inset = expanded_inset + (collapsed_inset - expanded_inset) * collapsed_progress; titlebar::drag_region( div() .id("chat-header") @@ -118,27 +130,18 @@ impl ChatScreen { .gap_3() .h(super::CHAT_HEADER_HEIGHT) .flex_none() - .pl_4() + .pl(inset) .pr_3() - .bg(gpui::rgb(theme::bg_sidebar())) - .when(self.sidebar_collapsed, |row| { - row.pl(SIDEBAR_COLLAPSED_INSET) - }), + .bg(gpui::rgb(theme::bg_sidebar())), ) .child( div() + .absolute() + .left(toolbar_left) + .top((super::CHAT_HEADER_HEIGHT - super::sidebar::TOP_ROW_HEIGHT) / 2.) .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( [ ( @@ -168,7 +171,7 @@ impl ChatScreen { ] .into_iter() .filter(|(_, _, _, _, command, _)| { - self.sidebar_collapsed || matches!(command, ChatCommand::ToggleSidebar) + collapsed_progress > 0. || matches!(command, ChatCommand::ToggleSidebar) }) .map(|(id, name, label, action, command, tab_index)| { widgets::icon_button_for_action( @@ -181,6 +184,11 @@ impl ChatScreen { ) .debug_selector(move || id.to_string()) .tab_index(tab_index) + .when(!matches!(command, ChatCommand::ToggleSidebar), |button| { + button + .opacity(collapsed_progress) + .tab_stop(self.sidebar_collapsed) + }) .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { cx.stop_propagation(); }) @@ -193,8 +201,17 @@ impl ChatScreen { }), ), ) - .when(self.sidebar_collapsed, |row| { - row.child(div().w(px(1.)).h_4().bg(gpui::rgb(theme::border()))) + .when(collapsed_progress > 0., |row| { + row.child( + div() + .absolute() + .left(toolbar_left + toolbar_width + header_gap) + .top((super::CHAT_HEADER_HEIGHT - window.rem_size()) / 2.) + .w(px(1.)) + .h_4() + .opacity(collapsed_progress) + .bg(gpui::rgb(theme::border())), + ) }) .child( div() diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 376ba4227..0aa45dcb5 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -109,6 +109,25 @@ struct QueueEdit { const SIDEBAR_WIDTH: gpui::Pixels = px(300.); /// Width of the rail that stands in for the sidebar while it is collapsed. const SIDEBAR_RAIL_WIDTH: gpui::Pixels = px(48.); +const SIDEBAR_TRANSITION_DURATION: std::time::Duration = std::time::Duration::from_millis(200); + +/// One reversible transition. Sampling the current position when retargeting +/// avoids jumping back to an endpoint on a second press. +struct SidebarTransition { + from: f32, + to: f32, + started: std::time::Instant, + duration: std::time::Duration, +} + +impl SidebarTransition { + fn value(&self, now: std::time::Instant) -> f32 { + let elapsed = now.saturating_duration_since(self.started).as_secs_f32(); + let t = (elapsed / self.duration.as_secs_f32()).clamp(0., 1.); + let eased = 1. - (1. - t).powi(3); + self.from + (self.to - self.from) * eased + } +} /// 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 @@ -579,6 +598,7 @@ pub struct ChatScreen { watched_git_dir: Option, /// Sidebar hidden; a toggle in the main pane brings it back. sidebar_collapsed: bool, + sidebar_transition: Option, /// Files staged for the next message. draft_attachments: Vec, unresolved_sends: HashMap, @@ -934,10 +954,45 @@ impl ChatScreen { if self.sidebar_collapsed == collapsed { return; } + let now = std::time::Instant::now(); + let from = self + .sidebar_transition + .as_ref() + .map(|transition| transition.value(now)) + .unwrap_or(if self.sidebar_collapsed { 0. } else { 1. }); + let to = if collapsed { 0. } else { 1. }; self.sidebar_collapsed = collapsed; + let distance = (to - from).abs(); + self.sidebar_transition = + (!cx.reduce_motion() && distance > f32::EPSILON).then(|| SidebarTransition { + from, + to, + started: now, + duration: SIDEBAR_TRANSITION_DURATION.mul_f32(distance), + }); + if collapsed { + self.sidebar.update(cx, |sidebar, cx| { + sidebar.close_popups(cx); + }); + } cx.notify(); } + fn sidebar_expansion(&mut self, window: &mut Window, cx: &Context) -> f32 { + if cx.reduce_motion() { + self.sidebar_transition = None; + } + let now = std::time::Instant::now(); + if let Some(transition) = &self.sidebar_transition { + if now.saturating_duration_since(transition.started) < transition.duration { + window.request_animation_frame(); + return transition.value(now); + } + self.sidebar_transition = None; + } + if self.sidebar_collapsed { 0. } else { 1. } + } + pub(super) fn toggle_sidebar_visibility(&mut self, cx: &mut Context) { self.set_sidebar_collapsed(!self.sidebar_collapsed, cx); } @@ -1290,6 +1345,7 @@ impl ChatScreen { dialog_focus_pending: false, trust_prompts: true, sidebar_collapsed: false, + sidebar_transition: None, draft_attachments: Vec::new(), unresolved_sends: HashMap::new(), attachment_uploads: HashMap::new(), @@ -6511,7 +6567,7 @@ impl Render for ChatScreen { .clone() .map(|status| self.render_trust_prompt(&status, cx)); let empty = self.shows_empty_state(); - let collapsed = self.sidebar_collapsed; + let sidebar_expansion = self.sidebar_expansion(window, cx); let loading_overlay = self.render_loading_overlay(); let main = if empty { self.render_empty_state(window, cx) @@ -6641,19 +6697,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. - row.child( - self.sidebar.clone().cached( - gpui::StyleRefinement::default() - .w(SIDEBAR_WIDTH) - .h_full() - .flex_none(), - ), - ) - }) + .child(self.render_sidebar(sidebar_expansion, cx)) .child( div() .relative() @@ -6666,7 +6710,7 @@ impl Render for ChatScreen { .child( // The header is the window's title bar; a wheel // over it scrolls nothing. - self.render_header(window, cx) + self.render_header(sidebar_expansion, window, cx) .on_scroll_wheel(|_, _, cx| cx.stop_propagation()), ) .child( @@ -6742,6 +6786,79 @@ impl Render for ChatScreen { } impl ChatScreen { + fn render_sidebar(&self, expansion: f32, cx: &mut Context) -> Div { + let width = SIDEBAR_RAIL_WIDTH + (SIDEBAR_WIDTH - SIDEBAR_RAIL_WIDTH) * expansion; + div() + .relative() + .flex_none() + .w(width) + .h_full() + .bg(gpui::rgb(theme::bg_sidebar())) + // Clip only during motion: settled sidebar menus may extend + // into the content pane as before. + .when(expansion < 1., |column| column.overflow_hidden()) + .when(expansion < 1., |column| { + column.child(self.render_sidebar_rail(cx)) + }) + .when(expansion > 0., |column| { + column.child( + div() + .absolute() + .top_0() + .left_0() + .w(SIDEBAR_WIDTH) + .h_full() + .opacity(expansion) + // Fixed bounds keep labels from wrapping as the + // outer column narrows; settled frames stay cached. + .child( + self.sidebar.clone().cached( + gpui::StyleRefinement::default() + .w(SIDEBAR_WIDTH) + .h_full() + .flex_none(), + ), + ), + ) + }) + // Keep one opaque Settings control outside the fading sidebar. + .child( + div() + .absolute() + .bottom_0() + .left_0() + .w(SIDEBAR_RAIL_WIDTH) + .h_12() + .flex() + .items_center() + .justify_center() + .bg(gpui::rgb(theme::bg_sidebar())) + .child( + widgets::icon_button_for_action( + "open-settings", + "settings", + "Settings", + px(16.), + theme::text_secondary(), + &OpenAppSettings, + ) + .debug_selector(|| "open-settings".to_string()) + .size(px(32.)) + .rounded_full() + .tab_index(6) + .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { + cx.stop_propagation(); + }) + .on_key_down(Self::sidebar_button_key_down) + .on_click(cx.listener( + |this, _event, window, cx| { + this.execute_command(ChatCommand::OpenSettings, window, cx); + }, + )), + ), + ) + } + /// Keep unsaved-work feedback beside the composer that produced it. /// The desktop owns retries so the local store's lifecycle guards still apply. fn render_persistence_failure(&self, cx: &mut Context) -> Option
{ diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 5dcdf178f..a6085bde7 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3044,38 +3044,6 @@ impl Sidebar { ) }) } - - /// Sidebar footer: just the settings gear. - fn render_footer(&self) -> Div { - let chat = self.chat.clone(); - let gear = div() - .id("open-settings") - .flex_none() - .size_8() - .flex() - .items_center() - .justify_center() - .rounded_full() - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) - .tooltip(widgets::tooltip_for_action( - "Settings", - &super::OpenAppSettings, - )) - .on_click(move |_event, window, cx| { - if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| { - chat.execute_command(ChatCommand::OpenSettings, window, cx); - }); - } - }) - .child(icon("settings", px(16.), theme::text_secondary())); - div().flex().items_center().px_3().py_2().child(gear) - } } impl Sidebar { @@ -3167,7 +3135,8 @@ impl Render for Sidebar { self.list.clone(), )), ) - .child(self.render_footer()) + // Settings stays opaque outside this cached, fading subtree. + .child(div().h_12().flex_none()) } } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 973fc9cb6..83976fe18 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5897,6 +5897,7 @@ mod state_tests { /// the expanded sidebar replaces the rail. #[gpui::test] fn collapsed_sidebar_rail_expands_it(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx); @@ -5923,6 +5924,7 @@ mod state_tests { /// same scale and origin, including device-pixel rounding. #[gpui::test] fn collapsed_sidebar_mark_matches_the_wordmark(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx); @@ -5957,6 +5959,7 @@ mod state_tests { /// pane layouts; expanding keeps only the same fixed toggle. #[gpui::test] fn collapsed_sidebar_header_toolbar(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx); @@ -6001,6 +6004,7 @@ mod state_tests { /// keyboard target when expanded. #[gpui::test] fn collapsed_sidebar_toggle_keeps_its_bounds(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx); @@ -6060,6 +6064,7 @@ mod state_tests { #[gpui::test] fn sidebar_search_button_and_shortcut(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = (0..80) .map(|index| summary(&format!("s{index}"), "Task")) @@ -6187,6 +6192,7 @@ mod state_tests { #[gpui::test] fn sidebar_search_button_keyboard(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |_, _| {}); let composer = cx.update(|_, app| chat.read(app).composer.clone().unwrap()); let composer_focus = cx.update(|_, app| composer.read(app).focus_handle(app)); @@ -6239,6 +6245,7 @@ mod state_tests { /// 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) { + cx.update(|cx| cx.set_reduce_motion(true)); 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()); @@ -6262,6 +6269,7 @@ mod state_tests { /// without forwarding a character to the composer. #[gpui::test] fn collapsed_sidebar_header_buttons_take_the_keyboard(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); 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()); @@ -6330,6 +6338,7 @@ mod state_tests { /// sidebar. #[gpui::test] fn collapsed_sidebar_rail_mark_takes_the_keyboard(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx);