From 18b57e7b17bd8bd86d6af274f52bd7f3610feafe Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:03:32 -0700 Subject: [PATCH 01/29] Agent: unify hover feedback and transitions --- apps/maple-agent/app/src/ui/chat/composer.rs | 92 +++- apps/maple-agent/app/src/ui/chat/mod.rs | 12 +- .../app/src/ui/chat/project_folder.rs | 10 +- apps/maple-agent/app/src/ui/chat/queue.rs | 14 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 131 ++++-- .../app/src/ui/chat/sidebar_computers.rs | 36 +- apps/maple-agent/app/src/ui/chat/speech.rs | 11 +- .../maple-agent/app/src/ui/chat/transcript.rs | 20 +- apps/maple-agent/app/src/ui/login.rs | 4 +- apps/maple-agent/app/src/ui/motion.rs | 427 +++++++++++++++++- apps/maple-agent/app/src/ui/popup.rs | 26 +- apps/maple-agent/app/src/ui/settings.rs | 31 +- apps/maple-agent/app/src/ui/titlebar.rs | 12 +- apps/maple-agent/app/src/ui/widgets.rs | 135 +++--- 14 files changed, 775 insertions(+), 186 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 8dae719c7..5ecdd33d2 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -294,10 +294,10 @@ impl ChatScreen { /// `button` as the opener of `popup`, with `menu` attached below or /// above it while the popup is open. - fn with_menu( + fn with_menu( &self, popup: ChatPopup, - button: gpui::Stateful
, + button: E, placement: Placement, window: &mut Window, cx: &mut Context, @@ -494,11 +494,15 @@ impl ChatScreen { .items_center() .justify_center() .rounded(theme::RADIUS_LG) - .hover(|style| { - style - .bg(gpui::rgb(theme::accent_container())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_pill()), + ) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_pill()))) .when(self.attachment_picking, |el| el.opacity(0.5)) .tooltip(widgets::tooltip(tr("chat-attach"), None)) @@ -519,11 +523,23 @@ impl ChatScreen { .justify_center() .rounded(theme::RADIUS_LG) .when(recording, |el| el.bg(gpui::rgb(theme::status_error()))) - .hover(|style| { - style - .bg(gpui::rgb(theme::accent_container())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if recording { + gpui::rgb(theme::status_error()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(if recording { + theme::status_error() + } else { + theme::bg_sidebar_pill() + }), + ) }) + .hover(|style| style.cursor_pointer()) .when(transcribing, |el| el.opacity(0.5)) .tooltip(widgets::tooltip( if recording { @@ -788,11 +804,19 @@ impl ChatScreen { .when(is_selected, |row| { row.bg(gpui::rgb(theme::bg_sidebar_row_selected())) }) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if is_selected { + gpui::rgb(theme::bg_sidebar_row_selected()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click(move |_event, _window, cx: &mut gpui::App| { chat.update(cx, |chat, cx| chat.complete_slash_command(&name, cx)) .ok(); @@ -958,7 +982,14 @@ impl ChatScreen { .cursor_pointer() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .hover(|style| style.bg(gpui::rgb(theme::bg_elevated()))) + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_elevated()), + ) + }) .on_click(cx.listener(|this, _event, _window, cx| { this.close_side_thread(cx); })) @@ -1104,7 +1135,10 @@ impl ChatScreen { .px_3() .py_2() .cursor_pointer() - .hover(|style| style.bg(gpui::rgb(theme::bg_elevated()))) + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background(element, rest, gpui::rgb(theme::bg_elevated())) + }) .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_plan_collapsed(cx); })) @@ -1425,11 +1459,15 @@ impl ChatScreen { .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::accent_container())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_pill()), + ) }) + .hover(|style| style.cursor_pointer()) .tooltip(widgets::tooltip( if expanded { tr("chat-shrink-composer") @@ -1613,7 +1651,7 @@ fn chip( chevron: bool, active: bool, highlight: bool, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let color = if highlight { theme::accent() } else if active { @@ -1638,11 +1676,15 @@ fn chip( .font_weight(gpui::FontWeight::MEDIUM) .text_color(gpui::rgb(color)) .when(active, |el| el.bg(gpui::rgb(theme::bg_sidebar_pill()))) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_pill())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if active { + gpui::rgb(theme::bg_sidebar_pill()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(element, rest, gpui::rgb(theme::bg_sidebar_pill())) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) .children(leading.map(|name| icon(name, px(16.), color))) .child(div().whitespace_nowrap().child(label)) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index b0b2a6fc5..0ae217fe6 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -7732,11 +7732,15 @@ impl ChatScreen { .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() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.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| { diff --git a/apps/maple-agent/app/src/ui/chat/project_folder.rs b/apps/maple-agent/app/src/ui/chat/project_folder.rs index e1e53c7be..b0926be3f 100644 --- a/apps/maple-agent/app/src/ui/chat/project_folder.rs +++ b/apps/maple-agent/app/src/ui/chat/project_folder.rs @@ -591,7 +591,15 @@ impl ProjectFolderDialog { .when(self.highlighted == Some(index), |row| { row.bg(gpui::rgb(theme::bg_sidebar_row_selected())) }) - .hover(|row| row.bg(theme::overlay_hover()).cursor_pointer()) + .map(|element| { + let rest: gpui::Hsla = if self.highlighted == Some(index) { + gpui::rgb(theme::bg_sidebar_row_selected()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(element, rest, theme::overlay_hover()) + }) + .hover(|row| row.cursor_pointer()) .on_click(cx.listener(move |this, _, window, cx| { window.focus(&this.focus(Control::List, cx), cx); this.enter_folder(index, cx); diff --git a/apps/maple-agent/app/src/ui/chat/queue.rs b/apps/maple-agent/app/src/ui/chat/queue.rs index 4b4523a2b..98a2d8576 100644 --- a/apps/maple-agent/app/src/ui/chat/queue.rs +++ b/apps/maple-agent/app/src/ui/chat/queue.rs @@ -329,13 +329,15 @@ impl ChatScreen { .justify_center() .rounded(theme::RADIUS_SM) .when(busy, |button| button.opacity(0.5)) - .when(!busy, |button| { - button.hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) + .map(|button| { + crate::ui::motion::hover_background( + button, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .when(busy, |button| button.highlighted(false)) + .when(!busy, |button| button.hover(|style| style.cursor_pointer())) .child(icon(icon_name, px(12.), theme::text_secondary())) }; Some( diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index ffc063e1f..3498902f7 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3141,11 +3141,15 @@ impl Sidebar { .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_selected()), + ) }) + .hover(|style| style.cursor_pointer()) .tooltip(widgets::tooltip_for_action( tr("chat-clear-search"), &super::ChatEscape, @@ -3235,11 +3239,15 @@ impl Sidebar { .border_color(gpui::transparent_black()) .font_weight(gpui::FontWeight::SEMIBOLD) }) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) .tooltip(widgets::tooltip_for_action(label.clone(), action)) .on_click(move |_event, window, cx| { @@ -3293,11 +3301,15 @@ impl Sidebar { .px_2() .py_1() .rounded(ROW_RADIUS) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_section_expanded(section, cx); this.hover_heading(SidebarEntry::SectionLabel(section), cx); @@ -3378,11 +3390,15 @@ impl Sidebar { .px_2() .h(px(34.)) .rounded(ROW_RADIUS) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_archived_visibility(cx); this.hover_heading(SidebarEntry::ArchivedHeader, cx); @@ -3705,7 +3721,7 @@ impl Sidebar { .into_any_element() } - fn view_button(&self, open: bool) -> gpui::Stateful
{ + fn view_button(&self, open: bool) -> crate::ui::motion::HoverBackground { header_button("view-options", "list-filter", tr("chat-view-options"), open) .debug_selector(|| "view-options".into()) .tab_index(5) @@ -3809,7 +3825,7 @@ impl Sidebar { } /// Add project stays visible until there is a project to browse. - fn new_project_button(&self, open: bool) -> gpui::Stateful
{ + fn new_project_button(&self, open: bool) -> crate::ui::motion::HoverBackground { header_button( "new-project", "folder-plus", @@ -3935,14 +3951,18 @@ impl Sidebar { .when(menu_open, |row| { row.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .when(hover, |row| { - row.hover(|style| { - if self.dragging { - style - } else { - style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - } - }) + .map(|row| { + let rest = if menu_open { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + row, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + .when(!hover || self.dragging, |row| row.highlighted(false)) }) .on_click(cx.listener(move |this, _event, _window, cx| { if !this.collapsed_projects.remove(&toggle_target) { @@ -4243,7 +4263,7 @@ impl Sidebar { application_selected: bool, window: &mut Window, cx: &mut Context, - ) -> gpui::Stateful
{ + ) -> crate::ui::motion::HoverBackground { let SidebarTaskEntry { session: index, pinned: is_pinned, @@ -4300,8 +4320,17 @@ impl Sidebar { .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 && !self.dragging, |el| { - el.hover(|style| style.bg(gpui::rgb(hover_bg)).cursor_pointer()) + .map(|row| { + let rest = if is_selected { + gpui::rgb(theme::bg_sidebar_row_selected()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(renaming || self.dragging, |row| row.highlighted(false)) + .when(!renaming && !self.dragging, |row| { + row.hover(|style| style.cursor_pointer()) + }) }) .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); @@ -4345,11 +4374,15 @@ impl Sidebar { .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_selected()), + ) }) + .hover(|style| style.cursor_pointer()) .tooltip(widgets::tooltip(tr("chat-unpin"), None)) .on_click(cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); @@ -4617,7 +4650,7 @@ pub(super) fn row_action( icon_name: &'static str, label: impl Into, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let label = label.into(); row_button(id, group, icon_name, label.clone()) .tooltip(widgets::tooltip(label, None)) @@ -4631,7 +4664,7 @@ fn row_menu_button( group: &SharedString, label: impl Into, open: bool, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let label = label.into(); row_button(id, group, "ellipsis", label).when(open, |button| { button @@ -4647,7 +4680,7 @@ fn header_button( icon_name: &'static str, label: impl Into, open: bool, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let label = label.into(); div() .id(id) @@ -4659,11 +4692,15 @@ fn header_button( .items_center() .justify_center() .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .when(open, |button| { button.bg(gpui::rgb(theme::bg_sidebar_row_selected())) }) @@ -4679,7 +4716,7 @@ fn row_button( group: &SharedString, icon_name: &'static str, label: impl Into, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let label = label.into(); div() .debug_selector(|| id.to_string()) @@ -4694,11 +4731,15 @@ fn row_button( .rounded(theme::RADIUS_SM) .opacity(0.) .group_hover(group.clone(), |style| style.opacity(1.)) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_selected()), + ) }) + .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::border()))) .child(icon(icon_name, px(14.), theme::text_secondary())) } 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 26efc936e..e749d795a 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2422,8 +2422,15 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .when(!loading, |row| { - row.hover(|s| s.cursor_pointer().bg(gpui::rgb(hover_bg))) + .map(|row| { + let rest = if selected { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(loading, |row| row.highlighted(false)) + .when(!loading, |row| row.hover(|style| style.cursor_pointer())) }) .on_click(cx.listener(move |this, _, _, cx| { if !loading { @@ -2472,12 +2479,16 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(move |s| { - if dragging || renaming { - s + .map(|row| { + let rest = if selected { + gpui::rgb(theme::bg_sidebar_row_hover()).into() } else { - s.bg(gpui::rgb(hover_bg)).cursor_pointer() - } + gpui::transparent_black() + }; + let still = dragging || renaming; + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(still, |row| row.highlighted(false)) + .when(!still, |row| row.hover(|style| style.cursor_pointer())) }) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) @@ -2584,12 +2595,11 @@ impl Sidebar { .gap_1() .text_xs() .text_color(gpui::rgb(theme::text_secondary())) - .hover(move |s| { - if dragging { - s - } else { - s.bg(gpui::rgb(hover_bg)).cursor_pointer() - } + .map(|row| { + let rest = gpui::transparent_black(); + crate::ui::motion::hover_background(row, rest, gpui::rgb(hover_bg)) + .when(dragging, |row| row.highlighted(false)) + .when(!dragging, |row| row.hover(|style| style.cursor_pointer())) }) .on_click(cx.listener(move |this, _, _, cx| { if !this.toggle_computer_group(&request, cx) { diff --git a/apps/maple-agent/app/src/ui/chat/speech.rs b/apps/maple-agent/app/src/ui/chat/speech.rs index cb682c8a2..cbf43e4da 100644 --- a/apps/maple-agent/app/src/ui/chat/speech.rs +++ b/apps/maple-agent/app/src/ui/chat/speech.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use gpui::{ - Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, div, px, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, }; use crate::ui::i18n::{UiText, tr}; @@ -224,7 +224,7 @@ pub(super) fn speak_message_button( text: SharedString, speech: Option<&SpeechState>, chat: gpui::WeakEntity, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { let item_id = item_id.to_string(); let (glyph, label) = match speech { None => ( @@ -257,9 +257,12 @@ pub(super) fn speak_message_button( .text_color(gpui::rgb(theme::text_muted())) .opacity(if active { 1. } else { 0. }) .group_hover(group.clone(), |style| style.opacity(1.)) + .map(|element| { + let rest: gpui::Hsla = gpui::transparent_black(); + crate::ui::motion::hover_background(element, rest, gpui::rgb(theme::bg_elevated())) + }) .hover(|style| { style - .bg(gpui::rgb(theme::bg_elevated())) .text_color(gpui::rgb(theme::text_secondary())) .cursor_pointer() }) diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 58373fdea..6e52c9f28 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -477,7 +477,7 @@ fn copy_message_button( group: &SharedString, text: SharedString, view: Option, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { widgets::copy_button( SharedString::from(format!("copy-message-{item_id}")), text, @@ -2007,11 +2007,21 @@ pub(super) fn render_question_card( && selection.is_some_and(|s| s.cursor == Some(option_index)), |row| row.bg(gpui::rgb(theme::bg_sidebar_row_hover())), ) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if entry.multi_select + && selection.is_some_and(|s| s.cursor == Some(option_index)) + { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_click({ cx.listener(move |this, _event, _window, cx| { this.toggle_question_option(question_index, option_index, cx); diff --git a/apps/maple-agent/app/src/ui/login.rs b/apps/maple-agent/app/src/ui/login.rs index fce4e62cf..f780b1c81 100644 --- a/apps/maple-agent/app/src/ui/login.rs +++ b/apps/maple-agent/app/src/ui/login.rs @@ -627,7 +627,7 @@ impl Render for LoginScreen { } } -fn submit_button(busy: bool, cx: &mut Context) -> gpui::Stateful
{ +fn submit_button(busy: bool, cx: &mut Context) -> crate::ui::motion::HoverBackground { widgets::primary_button("login-submit") .w_full() .mt_1() @@ -653,7 +653,7 @@ fn oauth_button( provider: OAuthProvider, busy: bool, cx: &mut Context, -) -> gpui::Stateful
{ +) -> crate::ui::motion::HoverBackground { widgets::secondary_button(gpui::SharedString::from(format!( "oauth-{}", provider.label().to_lowercase() diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 86c277557..4c149ff8a 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -11,8 +11,8 @@ //! gpui's oneshot `with_animation`: it requests frames only until the //! animation ends, then the window goes idle again. //! -//! Nothing here animates hover or press: gpui has no style transitions, -//! and a stepped scale change on hover reads as flicker. +//! Hover plates use a short, reversible color transition. Press feedback +//! remains immediate; no control scales or changes layout on hover. use std::cell::RefCell; use std::collections::HashSet; @@ -210,3 +210,426 @@ pub fn pulse(phase: f32, shift: f32, low: f32, high: f32) -> f32 { let wave = ((phase + shift) * std::f32::consts::TAU).sin() * 0.5 + 0.5; low + (high - low) * wave } + +/// A short, symmetric transition for hover plates. Press and focus styles +/// remain native GPUI refinements, applied after this background. +pub const HOVER: Duration = Duration::from_millis(120); + +#[derive(Clone, Copy)] +struct HoverTransition { + from: f32, + target: f32, + started: Instant, +} + +impl HoverTransition { + fn new(now: Instant) -> Self { + Self { + from: 0., + target: 0., + started: now, + } + } + + fn value(&self, now: Instant) -> f32 { + let t = (now.saturating_duration_since(self.started).as_secs_f32() / HOVER.as_secs_f32()) + .min(1.); + let eased = t * t * (3. - 2. * t); + self.from + (self.target - self.from) * eased + } + + fn update(&mut self, hovered: bool, now: Instant, reduced: bool) -> (f32, bool) { + let target = if hovered { 1. } else { 0. }; + if reduced { + self.from = target; + self.target = target; + } else if self.target != target { + self.from = self.value(now); + self.target = target; + self.started = now; + } + let value = self.value(now); + (value, value != self.target) + } +} + +// Premultiplied RGB avoids a dark flash when fading from transparent. +fn hover_color(rest: gpui::Hsla, hover: gpui::Hsla, amount: f32) -> gpui::Hsla { + if amount == 0. { + return rest; + } + if amount == 1. { + return hover; + } + let rest = gpui::Rgba::from(rest); + let hover = gpui::Rgba::from(hover); + let alpha = rest.a + (hover.a - rest.a) * amount; + let channel = |a: f32, b: f32| { + if alpha == 0. { + 0. + } else { + (a * rest.a * (1. - amount) + b * hover.a * amount) / alpha + } + }; + gpui::Rgba { + r: channel(rest.r, hover.r), + g: channel(rest.g, hover.g), + b: channel(rest.b, hover.b), + a: alpha, + } + .into() +} + +/// Animate a control's background without changing its layout, identity, +/// handlers, tooltip, or accessibility node. Keep native `.hover()` for +/// non-background styles and native `.active()` for immediate press feedback. +pub fn hover_background( + element: gpui::Stateful, + rest: impl Into, + hover: impl Into, +) -> HoverBackground { + HoverBackground { + element, + rest: rest.into(), + hover: hover.into(), + highlighted: None, + #[cfg(test)] + observed: None, + } +} + +pub struct HoverBackground { + element: gpui::Stateful, + rest: gpui::Hsla, + hover: gpui::Hsla, + highlighted: Option, + #[cfg(test)] + observed: Option>>, +} + +impl HoverBackground { + /// Override the resting background, including selected or open states. + pub fn bg(mut self, color: impl Into) -> Self { + self.rest = color.into(); + self.element.style().background = Some(self.rest.into()); + self + } + + /// Menus share one highlight between pointer and keyboard navigation. + /// Use that existing state instead of maintaining a second hover target. + pub fn highlighted(mut self, highlighted: bool) -> Self { + self.highlighted = Some(highlighted); + self + } +} + +impl Styled for HoverBackground { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.element.style() + } +} +impl gpui::InteractiveElement for HoverBackground { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + gpui::InteractiveElement::interactivity(&mut self.element) + } +} +impl gpui::StatefulInteractiveElement for HoverBackground {} +impl gpui::ParentElement for HoverBackground { + fn extend(&mut self, elements: impl IntoIterator) { + gpui::ParentElement::extend(&mut self.element, elements); + } +} +impl IntoElement for HoverBackground { + type Element = Self; + fn into_element(self) -> Self { + self + } +} + +impl Element for HoverBackground { + type RequestLayoutState = as Element>::RequestLayoutState; + type PrepaintState = as Element>::PrepaintState; + fn id(&self) -> Option { + self.element.id() + } + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + self.element.source_location() + } + fn a11y_role(&self) -> Option { + self.element.a11y_role() + } + fn write_a11y_info(&self, node: &mut gpui::accesskit::Node) { + self.element.write_a11y_info(node); + } + fn a11y_synthetic_children( + &mut self, + state: &mut Self::PrepaintState, + builder: &mut gpui::A11ySubtreeBuilder, + ) { + self.element.a11y_synthetic_children(state, builder); + } + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + self.element.request_layout(id, inspector, window, cx) + } + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector: Option<&InspectorElementId>, + bounds: Bounds, + layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.element + .prepaint(id, inspector, bounds, layout, window, cx) + } + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector: Option<&InspectorElementId>, + bounds: Bounds, + layout: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let hovered = self.highlighted.unwrap_or_else(|| { + hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.is_hovered(window)) + && !cx.has_active_drag() + }); + let now = Instant::now(); + let (amount, animating) = window.with_element_state( + id.expect("hover controls have an id"), + |state: Option, _| { + let mut state = state.unwrap_or_else(|| HoverTransition::new(now)); + let frame = state.update(hovered, now, cx.reduce_motion()); + (frame, state) + }, + ); + self.element.style().background = Some(hover_color(self.rest, self.hover, amount).into()); + if animating { + window.request_animation_frame(); + } + if self.highlighted.is_none() + && let Some(hitbox) = hitbox.clone() + { + let view = window.current_view(); + window.on_mouse_event(move |_: &gpui::MouseMoveEvent, phase, window, cx| { + if phase == gpui::DispatchPhase::Capture && hitbox.is_hovered(window) != hovered { + cx.notify(view); + } + }); + } + self.element + .paint(id, inspector, bounds, layout, hitbox, window, cx); + #[cfg(test)] + if let Some(observed) = &self.observed { + use gpui::InteractiveElement; + *observed.borrow_mut() = self + .element + .interactivity() + .compute_style(id, hitbox.as_ref(), window, cx) + .background + .unwrap_or_default(); + } + } +} + +#[cfg(test)] +mod hover_tests { + use super::*; + #[test] + fn hover_reverses_from_the_displayed_value_and_settles() { + let now = Instant::now(); + let mut transition = HoverTransition::new(now); + assert_eq!(transition.update(false, now, false), (0., false)); + assert_eq!(transition.update(true, now, false), (0., true)); + let halfway = now + HOVER / 2; + assert_eq!(transition.update(true, halfway, false), (0.5, true)); + assert_eq!(transition.update(false, halfway, false), (0.5, true)); + assert_eq!( + transition.update(false, halfway + HOVER, false), + (0., false) + ); + assert_eq!( + transition.update(false, halfway + HOVER * 2, false), + (0., false) + ); + } + #[test] + fn reduced_motion_settles_an_interrupted_hover_immediately() { + let now = Instant::now(); + let mut transition = HoverTransition::new(now); + transition.update(true, now, false); + assert_eq!(transition.update(true, now + HOVER / 4, true), (1., false)); + assert_eq!(transition.update(false, now + HOVER / 2, true), (0., false)); + } + + #[test] + fn hover_eases_symmetrically_without_darkening_transparent_colors() { + let now = Instant::now(); + let mut transition = HoverTransition::new(now); + transition.update(true, now, false); + let early = transition.value(now + HOVER / 4); + let late = transition.value(now + HOVER * 3 / 4); + assert!(early < 0.25 && late > 0.75); + assert!((early + late - 1.).abs() <= 2. * f32::EPSILON); + let white = gpui::hsla(0., 0., 1., 1.); + let middle = gpui::Rgba::from(hover_color(gpui::transparent_black(), white, 0.5)); + assert_eq!(middle.r, 1.); + assert_eq!(middle.g, 1.); + assert_eq!(middle.b, 1.); + assert_eq!(middle.a, 0.5); + } + + #[gpui::test] + fn shared_button_preserves_hover_click_and_pressed_feedback(cx: &mut gpui::TestAppContext) { + use gpui::{Modifiers, MouseButton, div, prelude::*, px}; + use std::rc::Rc; + struct Host { + observed: Rc>, + clicks: Rc>, + hovered: Rc>, + } + impl gpui::Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let clicks = self.clicks.clone(); + let hovered = self.hovered.clone(); + let mut button = super::super::widgets::secondary_button("hover-test") + .debug_selector(|| "hover-test".into()) + .on_hover(move |value, _, _| hovered.set(*value)) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)) + .child("Action"); + button.observed = Some(self.observed.clone()); + div().size_full().child(button) + } + } + let observed = Rc::new(RefCell::new(gpui::Fill::default())); + let clicks = Rc::new(std::cell::Cell::new(0)); + let hovered = Rc::new(std::cell::Cell::new(false)); + cx.update(|cx| cx.set_reduce_motion(true)); + let (_, cx) = cx.add_window_view(|_, _| Host { + observed: observed.clone(), + clicks: clicks.clone(), + hovered: hovered.clone(), + }); + cx.run_until_parked(); + let bounds = cx.debug_bounds("hover-test").unwrap(); + cx.simulate_mouse_move(bounds.center(), None, Modifiers::default()); + assert!(hovered.get()); + assert_eq!( + *observed.borrow(), + gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_row_selected())) + ); + cx.simulate_mouse_down(bounds.center(), MouseButton::Left, Modifiers::default()); + assert_eq!( + *observed.borrow(), + gpui::Fill::from(gpui::rgb(super::super::theme::border())) + ); + cx.simulate_mouse_up(bounds.center(), MouseButton::Left, Modifiers::default()); + assert_eq!(clicks.get(), 1); + cx.simulate_mouse_move( + gpui::point(bounds.right() + px(20.), bounds.bottom() + px(20.)), + None, + Modifiers::default(), + ); + assert!(!hovered.get()); + assert_eq!( + *observed.borrow(), + gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_pill())) + ); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + } + + #[gpui::test] + fn cached_child_hover_animates_reverses_and_goes_idle(cx: &mut gpui::TestAppContext) { + use gpui::{Modifiers, div, prelude::*}; + use std::{cell::Cell, rc::Rc}; + struct Control { + observed: Rc>, + renders: Rc>, + } + impl gpui::Render for Control { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + self.renders.set(self.renders.get() + 1); + let mut button = super::super::widgets::secondary_button("cached-hover") + .debug_selector(|| "cached-hover".into()) + .child("Action"); + button.observed = Some(self.observed.clone()); + div().size_full().child(button) + } + } + struct Host { + control: gpui::Entity, + } + impl gpui::Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().size_full().child( + self.control.clone().cached( + gpui::StyleRefinement::default() + .w(gpui::px(200.)) + .h(gpui::px(80.)), + ), + ) + } + } + let observed = Rc::new(RefCell::new(gpui::Fill::default())); + let child_renders = Rc::new(Cell::new(0)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (_, cx) = cx.add_window_view(|_, cx| Host { + control: cx.new(|_| Control { + observed: observed.clone(), + renders: child_renders.clone(), + }), + }); + cx.run_until_parked(); + let rest = gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_pill())); + let hover = gpui::Fill::from(gpui::rgb(super::super::theme::bg_sidebar_row_selected())); + let bounds = cx.debug_bounds("cached-hover").unwrap(); + let outside = gpui::point( + bounds.right() + gpui::px(20.), + bounds.bottom() + gpui::px(20.), + ); + cx.simulate_mouse_move(outside, None, Modifiers::default()); + let child_count = child_renders.get(); + cx.simulate_mouse_move(bounds.center(), None, Modifiers::default()); + assert!(cx.update(|window, cx| window.simulate_next_frame(cx)) > 0); + cx.run_until_parked(); + // The primitive uses real monotonic time; the test executor's clock + // does not advance Instant. One bounded wait samples an in-flight fade. + std::thread::sleep(HOVER / 4); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_ne!(*observed.borrow(), rest); + cx.simulate_mouse_move(outside, None, Modifiers::default()); + assert_ne!(*observed.borrow(), rest, "leaving must not snap to rest"); + assert!(cx.update(|window, cx| window.simulate_next_frame(cx)) > 0); + cx.run_until_parked(); + std::thread::sleep(HOVER + Duration::from_millis(20)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_eq!(*observed.borrow(), rest); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + cx.simulate_mouse_move(bounds.center(), None, Modifiers::default()); + std::thread::sleep(HOVER + Duration::from_millis(20)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_eq!(*observed.borrow(), hover); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + assert!(child_renders.get() > child_count); + } +} diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index 621b87cab..f39191b64 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -429,13 +429,13 @@ impl Popup { /// closed trigger stays an ordinary button: it shows its pressed state, /// and any other open popup still sees the press and closes. Commands /// and keys that open a menu call [`Self::open`] instead. - pub(crate) fn trigger( + pub(crate) fn trigger( &self, key: K, - button: Stateful
, + button: E, cx: &mut Context, toggle: impl Fn(&mut V, &mut Window, &mut Context) + 'static, - ) -> Stateful
{ + ) -> E { let open = self.is_open(&key); let get = self.get; button @@ -1249,10 +1249,7 @@ fn item_row( }; row.aria_toggled(if on { Toggled::True } else { Toggled::False }) }) - .when(highlighted, |row| { - row.aria_active_descendant() - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }) + .when(highlighted, |row| row.aria_active_descendant()) .flex() .items_center() .gap_2() @@ -1387,10 +1384,17 @@ fn item_row( .children(submenu.panel) }) .relative(); - match style { - Some(style) => style(row_element).into_any_element(), - None => row_element.into_any_element(), - } + let row_element = match style { + Some(style) => style(row_element), + None => row_element, + }; + crate::ui::motion::hover_background( + row_element, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + .highlighted(highlighted) + .into_any_element() } #[derive(Clone, Copy)] diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index f7116e5d1..427d8f082 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -1820,7 +1820,20 @@ impl SettingsScreen { .when(application_selected, |row| { row.border_l_2().border_color(gpui::rgb(theme::accent())) }) - .hover(|style| style.bg(gpui::rgb(theme::bg_elevated())).cursor_pointer()) + .map(|element| { + let rest: gpui::Hsla = gpui::rgb(if selected { + theme::bg_elevated() + } else { + theme::bg_sidebar() + }) + .into(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_elevated()), + ) + }) + .hover(|style| style.cursor_pointer()) .on_click({ let section = *section; cx.listener(move |this, _event, _window, cx| { @@ -2868,11 +2881,19 @@ impl SettingsScreen { theme::text_secondary() })) .when(active, |el| el.bg(gpui::rgb(theme::bg_sidebar_row_hover()))) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = if active { + gpui::rgb(theme::bg_sidebar_row_hover()).into() + } else { + gpui::transparent_black() + }; + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_sidebar_row_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .child(label) }; div() diff --git a/apps/maple-agent/app/src/ui/titlebar.rs b/apps/maple-agent/app/src/ui/titlebar.rs index 49f045502..d363f0920 100644 --- a/apps/maple-agent/app/src/ui/titlebar.rs +++ b/apps/maple-agent/app/src/ui/titlebar.rs @@ -180,11 +180,15 @@ fn control( .bg(gpui::rgb(theme::bg_title_control())) .text_xs() .text_color(gpui::rgb(theme::text_primary())) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_title_control_hover())) - .cursor_pointer() + .map(|element| { + let rest: gpui::Hsla = gpui::rgb(theme::bg_title_control()).into(); + crate::ui::motion::hover_background( + element, + rest, + gpui::rgb(theme::bg_title_control_hover()), + ) }) + .hover(|style| style.cursor_pointer()) .on_mouse_down(gpui::MouseButton::Left, { let action = std::rc::Rc::new(action); move |_event, window, cx| { diff --git a/apps/maple-agent/app/src/ui/widgets.rs b/apps/maple-agent/app/src/ui/widgets.rs index 35a4e0a15..8b1c7c0d7 100644 --- a/apps/maple-agent/app/src/ui/widgets.rs +++ b/apps/maple-agent/app/src/ui/widgets.rs @@ -1,9 +1,8 @@ //! Shared chrome for the gpui screens: popup panels, buttons, switches, //! tooltips, card rows, input frames, and banners. //! -//! Every helper returns a bare `div()` builder, so it costs the same as -//! the inline chain it replaces: no allocation, no parsing, nothing -//! cached. Callers keep their own ids, children, and click handlers. +//! Helpers preserve the div builder API and callers keep their ids, children, +//! and click handlers. Hover backgrounds share a short reversible transition. //! //! Shapes follow the brand kit: buttons are pills, small controls use //! `theme::RADIUS_SM`, popups and inputs `theme::RADIUS_MD`, cards @@ -20,6 +19,7 @@ use gpui::{ }; use super::icons::icon; +use super::motion::{HoverBackground, hover_background}; use super::theme; /// How long an icon-only control waits before showing its tooltip. Icons @@ -92,49 +92,60 @@ fn pill(id: impl Into) -> Stateful
{ } /// Accent-filled action button: coral pill with a light label. -pub fn primary_button(id: impl Into) -> Stateful
{ - pill(id) - .bg(gpui::rgb(theme::accent())) - .text_color(gpui::rgb(theme::on_accent())) - .hover(|style| style.bg(gpui::rgb(theme::accent_hover())).cursor_pointer()) - .active(|style| press_depth(style.bg(gpui::rgb(theme::send_bottom())))) +pub fn primary_button(id: impl Into) -> HoverBackground { + hover_background( + pill(id) + .bg(gpui::rgb(theme::accent())) + .text_color(gpui::rgb(theme::on_accent())) + .hover(|style| style.cursor_pointer()) + .active(|style| press_depth(style.bg(gpui::rgb(theme::send_bottom())))), + gpui::rgb(theme::accent()), + gpui::rgb(theme::accent_hover()), + ) } /// Neutral pill for the second action beside a [`primary_button`] /// (Cancel, Back, alternate sign-in): a pebble fill that darkens on hover. -pub fn secondary_button(id: impl Into) -> Stateful
{ - pill(id) - .bg(gpui::rgb(theme::bg_sidebar_pill())) - .text_color(gpui::rgb(theme::text_primary())) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() - }) - .active(|style| press_depth(style.bg(gpui::rgb(theme::border())))) +pub fn secondary_button(id: impl Into) -> HoverBackground { + hover_background( + pill(id) + .bg(gpui::rgb(theme::bg_sidebar_pill())) + .text_color(gpui::rgb(theme::text_primary())) + .hover(|style| style.cursor_pointer()) + .active(|style| press_depth(style.bg(gpui::rgb(theme::border())))), + gpui::rgb(theme::bg_sidebar_pill()), + gpui::rgb(theme::bg_sidebar_row_selected()), + ) } /// Text-only button for low-emphasis actions: no fill, no border, and /// the label brightens on hover. -pub fn ghost_button(id: impl Into) -> Stateful
{ - pill(id) - .text_color(gpui::rgb(theme::text_secondary())) - .hover(|style| { - style - .text_color(gpui::rgb(theme::text_primary())) - .bg(theme::overlay_hover()) - .cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_pill()))) +pub fn ghost_button(id: impl Into) -> HoverBackground { + hover_background( + pill(id) + .text_color(gpui::rgb(theme::text_secondary())) + .hover(|style| { + style + .text_color(gpui::rgb(theme::text_primary())) + .cursor_pointer() + }) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_pill()))), + gpui::transparent_black(), + theme::overlay_hover(), + ) } /// Destructive pill (Remove, Deny): error fill with a light label. -pub fn danger_button(id: impl Into) -> Stateful
{ - pill(id) - .bg(gpui::rgb(theme::status_error())) - .text_color(gpui::rgb(theme::on_accent())) - .hover(|style| style.opacity(0.9).cursor_pointer()) - .active(|style| press_depth(style.opacity(0.85))) +pub fn danger_button(id: impl Into) -> HoverBackground { + let error: Hsla = gpui::rgb(theme::status_error()).into(); + hover_background( + pill(id) + .text_color(gpui::rgb(theme::on_accent())) + .hover(|style| style.cursor_pointer()) + .active(|style| press_depth(style.opacity(0.85))), + error, + gpui::hsla(error.h, error.s, error.l, 0.9), + ) } /// Square icon-only button with a hover plate, used in list rows and @@ -147,7 +158,7 @@ pub fn icon_button( label: impl Into, size: Pixels, color: u32, -) -> Stateful
{ +) -> HoverBackground { let label = label.into(); icon_button_base(id, name, label.clone(), size, color).tooltip(tooltip(label, None)) } @@ -162,7 +173,7 @@ pub fn icon_button_for_action( size: Pixels, color: u32, action: &dyn Action, -) -> Stateful
{ +) -> HoverBackground { let label = label.into(); icon_button_base(id, name, label.clone(), size, color) .tooltip(tooltip_for_action(label, action)) @@ -174,26 +185,26 @@ fn icon_button_base( label: impl Into, size: Pixels, color: u32, -) -> Stateful
{ - div() - .id(id) - .role(Role::Button) - .aria_label(label) - .flex_none() - .size_7() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) - .focus_visible(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()))) - .tooltip_show_delay(ICON_TOOLTIP_DELAY) - .child(icon(name, size, color)) +) -> HoverBackground { + hover_background( + div() + .id(id) + .role(Role::Button) + .aria_label(label) + .flex_none() + .size_7() + .flex() + .items_center() + .justify_center() + .rounded(theme::RADIUS_SM) + .focus_visible(focus_ring) + .hover(|style| style.cursor_pointer()) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) + .tooltip_show_delay(ICON_TOOLTIP_DELAY) + .child(icon(name, size, color)), + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) } /// On/off switch: a pill track with a knob that sits right when `on`. @@ -623,7 +634,7 @@ pub fn copy_button( text: SharedString, reveal: Option<&SharedString>, view: Option, -) -> Stateful
{ +) -> super::motion::HoverBackground { let id = id.into(); let copied = copied_recently(&id); let click_id = id.clone(); @@ -651,7 +662,6 @@ pub fn copy_button( .focus_visible(focus_ring) .hover(|style| { style - .bg(theme::overlay_hover()) .text_color(gpui::rgb(theme::text_secondary())) .cursor_pointer() }) @@ -685,6 +695,13 @@ pub fn copy_button( } else { "common-copy" })) + .map(|button| { + super::motion::hover_background( + button, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + }) } #[cfg(test)] From dac283edfd23aaed3624cd09c1c0b13a7e91290a Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:07:49 -0700 Subject: [PATCH 02/29] Agent: keep hover changes compatible with sidebar polish --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 3498902f7..11ec8e93e 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3239,15 +3239,10 @@ impl Sidebar { .border_color(gpui::transparent_black()) .font_weight(gpui::FontWeight::SEMIBOLD) }) - .map(|element| { - let rest: gpui::Hsla = gpui::transparent_black(); - crate::ui::motion::hover_background( - element, - rest, - gpui::rgb(theme::bg_sidebar_row_hover()), - ) + .hover(|style| { + // Pointer feedback stays immediate while the background eases. + style.cursor_pointer() }) - .hover(|style| style.cursor_pointer()) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) .tooltip(widgets::tooltip_for_action(label.clone(), action)) .on_click(move |_event, window, cx| { @@ -3259,6 +3254,13 @@ impl Sidebar { }) .child(icon(name, px(16.), color)) .child(label) + .map(|element| { + crate::ui::motion::hover_background( + element, + gpui::transparent_black(), + gpui::rgb(theme::bg_sidebar_row_hover()), + ) + }) .into_any_element() } Some(SidebarEntry::ProjectsHeader) => self.render_projects_header(window, cx), From f829b66795c27d3a314ad269c29e42220846fcc1 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:02:25 -0700 Subject: [PATCH 03/29] Agent: ease Projects control visibility --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 149 ++++++++++++++++-- apps/maple-agent/app/src/ui/motion.rs | 161 ++++++++++++++++++-- 2 files changed, 285 insertions(+), 25 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 11ec8e93e..464a3b5f9 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3572,14 +3572,20 @@ impl Sidebar { fn set_hovered_entry(&mut self, entry: Option, cx: &mut Context) { self.hover_scroll_top = self.list.logical_scroll_top(); + let header_was_hovered = self.projects_header_hovered(); self.hovered_entry = entry; let section = entry.and_then(|entry| self.entry_sections.get(entry).copied().flatten()); - if self.hovered_section != section { + if self.hovered_section != section || header_was_hovered != self.projects_header_hovered() { self.hovered_section = section; cx.notify(); } } + fn projects_header_hovered(&self) -> bool { + self.hovered_entry.and_then(|ix| self.entries.get(ix)) + == Some(&SidebarEntry::ProjectsHeader) + } + #[cfg(test)] pub(super) fn section_hover_is_clear_for_test(&self) -> bool { self.hovered_entry.is_none() && self.hovered_section.is_none() @@ -3599,17 +3605,13 @@ impl Sidebar { id: &'static str, section: SidebarHoverSection, ) -> AnyElement { + let visible = !expanded || self.section_hovered(section); let chevron = div() .id((id, 0_u64)) .flex_none() .size(px(14.)) .flex() .items_center() - .opacity(if !expanded || self.section_hovered(section) { - 1. - } else { - 0. - }) .child(icon( if expanded { "chevron-down" @@ -3619,7 +3621,20 @@ impl Sidebar { px(14.), theme::text_secondary(), )); - chevron.into_any_element() + if section == SidebarHoverSection::Projects { + crate::ui::motion::hover_background( + chevron, + gpui::transparent_black(), + gpui::transparent_black(), + ) + .highlighted(false) + .revealed(visible) + .into_any_element() + } else { + chevron + .opacity(if visible { 1. } else { 0. }) + .into_any_element() + } } /// The Projects header: its label folds the list under it, and its @@ -3725,14 +3740,13 @@ impl Sidebar { fn view_button(&self, open: bool) -> crate::ui::motion::HoverBackground { header_button("view-options", "list-filter", tr("chat-view-options"), open) + .revealed(open) .debug_selector(|| "view-options".into()) .tab_index(5) .focus_visible(|style| widgets::focus_ring(style.opacity(1.))) .on_key_down(ChatScreen::sidebar_button_key_down) .when(!open, |button| { - button - .opacity(0.) - .group_hover("projects-header-actions", |style| style.opacity(1.)) + button.revealed(self.projects_header_hovered()) }) } @@ -3835,11 +3849,7 @@ impl Sidebar { open, ) .debug_selector(|| "new-project".to_owned()) - .when(!open && !self.project_roots.is_empty(), |button| { - button - .opacity(0.) - .group_hover("projects-header-actions", |style| style.opacity(1.)) - }) + .revealed(open || self.project_roots.is_empty() || self.projects_header_hovered()) } fn new_project_computer_menu(&self) -> Menu { @@ -4808,6 +4818,115 @@ fn remote_root_display_name(root: &str) -> String { mod tests { use super::{remote_root_display_name, root_display_name}; + #[gpui::test] + fn projects_header_reveal_tracks_its_buttons_and_keeps_view_menu_open( + cx: &mut gpui::TestAppContext, + ) { + use super::*; + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + cx.update(|cx| cx.set_reduce_motion(true)); + 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.reset_persisted_for_test(); + sidebar.view.group = SidebarGroup::List; + sidebar.set_sessions( + (1..=100) + .map(|ix| AgentSessionSummary { + id: format!("s{ix}"), + title: format!("Task {ix}"), + project_root: "/tmp/proj".into(), + created_ms: 0, + updated_ms: 100 - ix, + message_count: 1, + model: None, + web_enabled: true, + permission_mode: Default::default(), + state: AgentTaskState::Active, + acp: false, + }) + .collect(), + cx, + ); + }); + 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(600.), px(400.))); + cx.run_until_parked(); + let outside = gpui::point(px(500.), px(350.)); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + let header = cx.debug_bounds("projects-header").unwrap(); + let view = cx.debug_bounds("view-options").unwrap(); + let add = cx.debug_bounds("new-project").unwrap(); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + + for position in [header.center(), view.center(), add.center()] { + cx.simulate_mouse_move(position, None, gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + assert_eq!(cx.debug_bounds("projects-header"), Some(header)); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + assert_eq!(cx.debug_bounds("new-project"), Some(add)); + } + let task = cx.debug_bounds("session-s1").unwrap(); + cx.simulate_mouse_move(task.center(), None, gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| { + sidebar.section_hovered(SidebarHoverSection::Projects) + })); + assert!( + !sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered()), + "hovering a task in the Projects section must not reveal header actions" + ); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + assert_eq!(cx.debug_bounds("new-project"), Some(add)); + + cx.simulate_mouse_move(header.center(), None, gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + cx.simulate_event(gpui::ScrollWheelEvent { + position: header.center(), + delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(-1600.))), + ..Default::default() + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("projects-header").is_none()); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + sidebar.update(cx, |sidebar, cx| { + sidebar.list.scroll_to(gpui::ListOffset::default()); + cx.notify(); + }); + cx.run_until_parked(); + assert_eq!(cx.debug_bounds("projects-header"), Some(header)); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + assert_eq!(cx.debug_bounds("new-project"), Some(add)); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + + cx.simulate_mouse_move(header.center(), None, gpui::Modifiers::default()); + cx.simulate_mouse_move(view.center(), None, gpui::Modifiers::default()); + cx.simulate_click(view.center(), gpui::Modifiers::default()); + assert!(sidebar.update(cx, |sidebar, _| sidebar.popup.is_open(&SidebarPopup::View))); + assert!(cx.debug_bounds("sidebar-group").is_some()); + cx.simulate_mouse_move(outside, None, gpui::Modifiers::default()); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.projects_header_hovered())); + assert!(sidebar.update(cx, |sidebar, _| sidebar.popup.is_open(&SidebarPopup::View))); + assert!(cx.debug_bounds("sidebar-group").is_some()); + assert_eq!(cx.debug_bounds("view-options"), Some(view)); + } + #[gpui::test] fn collapsed_and_empty_projects_keep_the_expanded_task_gap(cx: &mut gpui::TestAppContext) { use super::*; diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 4c149ff8a..3007b3ea5 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -211,8 +211,8 @@ pub fn pulse(phase: f32, shift: f32, low: f32, high: f32) -> f32 { low + (high - low) * wave } -/// A short, symmetric transition for hover plates. Press and focus styles -/// remain native GPUI refinements, applied after this background. +/// A short, symmetric transition for hover plates and control reveals. +/// Press and focus styles remain native GPUI refinements. pub const HOVER: Duration = Duration::from_millis(120); #[derive(Clone, Copy)] @@ -223,10 +223,11 @@ struct HoverTransition { } impl HoverTransition { - fn new(now: Instant) -> Self { + fn new(visible: bool, now: Instant) -> Self { + let value = if visible { 1. } else { 0. }; Self { - from: 0., - target: 0., + from: value, + target: value, started: now, } } @@ -253,6 +254,9 @@ impl HoverTransition { } } +// Visibility and background hover can have different targets on one control. +struct RevealTransition(HoverTransition); + // Premultiplied RGB avoids a dark flash when fading from transparent. fn hover_color(rest: gpui::Hsla, hover: gpui::Hsla, amount: f32) -> gpui::Hsla { if amount == 0. { @@ -293,8 +297,13 @@ pub fn hover_background( rest: rest.into(), hover: hover.into(), highlighted: None, + revealed: None, #[cfg(test)] observed: None, + #[cfg(test)] + observed_opacity: None, + #[cfg(test)] + test_now: None, } } @@ -303,8 +312,13 @@ pub struct HoverBackground { rest: gpui::Hsla, hover: gpui::Hsla, highlighted: Option, + revealed: Option, #[cfg(test)] observed: Option>>, + #[cfg(test)] + observed_opacity: Option>>, + #[cfg(test)] + test_now: Option, } impl HoverBackground { @@ -321,6 +335,13 @@ impl HoverBackground { self.highlighted = Some(highlighted); self } + + /// Fade visibility in place, keeping the control's hitbox and layout. + /// Native focus styles can still reveal a keyboard target immediately. + pub fn revealed(mut self, visible: bool) -> Self { + self.revealed = Some(visible); + self + } } impl Styled for HoverBackground { @@ -406,16 +427,34 @@ impl Element for HoverBackground { && !cx.has_active_drag() }); let now = Instant::now(); + #[cfg(test)] + let now = self.test_now.unwrap_or(now); let (amount, animating) = window.with_element_state( id.expect("hover controls have an id"), |state: Option, _| { - let mut state = state.unwrap_or_else(|| HoverTransition::new(now)); + let mut state = state.unwrap_or_else(|| HoverTransition::new(false, now)); let frame = state.update(hovered, now, cx.reduce_motion()); (frame, state) }, ); self.element.style().background = Some(hover_color(self.rest, self.hover, amount).into()); - if animating { + let reveal_animating = if let Some(visible) = self.revealed { + let (opacity, animating) = window.with_element_state( + id.expect("revealed controls have an id"), + |state: Option, _| { + // An initially visible control should not fade in on mount. + let mut state = state + .unwrap_or_else(|| RevealTransition(HoverTransition::new(visible, now))); + let frame = state.0.update(visible, now, cx.reduce_motion()); + (frame, state) + }, + ); + self.element.style().opacity = Some(opacity); + animating + } else { + false + }; + if animating || reveal_animating { window.request_animation_frame(); } if self.highlighted.is_none() @@ -440,6 +479,17 @@ impl Element for HoverBackground { .background .unwrap_or_default(); } + #[cfg(test)] + if let Some(observed) = &self.observed_opacity { + use gpui::InteractiveElement; + observed.set( + self.element + .interactivity() + .compute_style(id, hitbox.as_ref(), window, cx) + .opacity + .unwrap_or(1.), + ); + } } } @@ -449,7 +499,7 @@ mod hover_tests { #[test] fn hover_reverses_from_the_displayed_value_and_settles() { let now = Instant::now(); - let mut transition = HoverTransition::new(now); + let mut transition = HoverTransition::new(false, now); assert_eq!(transition.update(false, now, false), (0., false)); assert_eq!(transition.update(true, now, false), (0., true)); let halfway = now + HOVER / 2; @@ -467,7 +517,7 @@ mod hover_tests { #[test] fn reduced_motion_settles_an_interrupted_hover_immediately() { let now = Instant::now(); - let mut transition = HoverTransition::new(now); + let mut transition = HoverTransition::new(false, now); transition.update(true, now, false); assert_eq!(transition.update(true, now + HOVER / 4, true), (1., false)); assert_eq!(transition.update(false, now + HOVER / 2, true), (0., false)); @@ -476,7 +526,7 @@ mod hover_tests { #[test] fn hover_eases_symmetrically_without_darkening_transparent_colors() { let now = Instant::now(); - let mut transition = HoverTransition::new(now); + let mut transition = HoverTransition::new(false, now); transition.update(true, now, false); let early = transition.value(now + HOVER / 4); let late = transition.value(now + HOVER * 3 / 4); @@ -490,6 +540,97 @@ mod hover_tests { assert_eq!(middle.a, 0.5); } + #[gpui::test] + fn reveal_fades_reverses_and_preserves_focus_and_geometry(cx: &mut gpui::TestAppContext) { + use gpui::{div, prelude::*, px}; + use std::{cell::Cell, rc::Rc}; + struct Host { + visible: bool, + now: Instant, + focus: gpui::FocusHandle, + observed: Rc>, + } + impl gpui::Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let mut button = super::super::widgets::secondary_button("reveal-test") + .debug_selector(|| "reveal-test".into()) + .track_focus(&self.focus) + .focus_visible(|style| style.opacity(1.)) + .highlighted(false) + .revealed(self.visible) + .child("Action"); + button.observed_opacity = Some(self.observed.clone()); + button.test_now = Some(self.now); + div().size_full().p(px(20.)).child(button) + } + } + let observed = Rc::new(Cell::new(0.)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (host, cx) = cx.add_window_view(|_, cx| Host { + visible: true, + now: Instant::now(), + focus: cx.focus_handle(), + observed: observed.clone(), + }); + cx.run_until_parked(); + let bounds = cx.debug_bounds("reveal-test").unwrap(); + assert_eq!( + observed.get(), + 1., + "visible controls do not fade in on mount" + ); + let set_visible = |visible, cx: &mut gpui::VisualTestContext| { + host.update(cx, |host, cx| { + host.visible = visible; + cx.notify(); + }); + cx.run_until_parked(); + }; + let frame_after = |duration, cx: &mut gpui::VisualTestContext| { + host.update(cx, |host, cx| { + host.now += duration; + cx.notify(); + }); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + }; + set_visible(false, cx); + assert!(observed.get() > 0., "hide must not snap off"); + frame_after(HOVER / 4, cx); + assert!(observed.get() > 0. && observed.get() < 1.); + set_visible(true, cx); + assert!( + observed.get() > 0. && observed.get() < 1., + "reverse in place" + ); + frame_after(HOVER, cx); + assert_eq!(observed.get(), 1.); + set_visible(false, cx); + frame_after(HOVER, cx); + assert_eq!(observed.get(), 0.); + set_visible(true, cx); + assert!(observed.get() < 1., "show must not snap on"); + frame_after(HOVER, cx); + assert_eq!(observed.get(), 1.); + assert_eq!(cx.debug_bounds("reveal-test").unwrap(), bounds); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + + cx.update(|_, cx| cx.set_reduce_motion(true)); + set_visible(false, cx); + assert_eq!(observed.get(), 0.); + set_visible(true, cx); + assert_eq!(observed.get(), 1.); + set_visible(false, cx); + let focus = host.update(cx, |host, _| host.focus.clone()); + cx.simulate_keystrokes("tab"); + cx.update(|window, cx| focus.focus(window, cx)); + cx.run_until_parked(); + assert_eq!(observed.get(), 1., "keyboard focus reveals immediately"); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + } + #[gpui::test] fn shared_button_preserves_hover_click_and_pressed_feedback(cx: &mut gpui::TestAppContext) { use gpui::{Modifiers, MouseButton, div, prelude::*, px}; From 21a7c6231e9cc07e54fe5e314f36ad53de7f29a1 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:04:39 -0800 Subject: [PATCH 04/29] Agent: carry hover transitions onto master --- apps/maple-agent/app/src/ui/plan_gate.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/maple-agent/app/src/ui/plan_gate.rs b/apps/maple-agent/app/src/ui/plan_gate.rs index 31fa162f8..ee4752da8 100644 --- a/apps/maple-agent/app/src/ui/plan_gate.rs +++ b/apps/maple-agent/app/src/ui/plan_gate.rs @@ -123,15 +123,16 @@ impl Render for PlanGate { } let busy = self.checking || self.signing_out; let width = (window.viewport_size().width - px(48.)).clamp(px(0.), px(500.)); - let button = - |button: gpui::Stateful, selector: &'static str, label: SharedString| { - button - .debug_selector(move || selector.to_string()) - .aria_label(label.clone()) - .py_1p5() - .when(busy, |button| button.opacity(0.6)) - .child(label) - }; + let button = |button: crate::ui::motion::HoverBackground, + selector: &'static str, + label: SharedString| { + button + .debug_selector(move || selector.to_string()) + .aria_label(label.clone()) + .py_1p5() + .when(busy, |button| button.opacity(0.6)) + .child(label) + }; div() .relative() .flex_1() From 0769e9fa7cd5b7ce44773dc099b73dc6894a113e Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:14:23 -0800 Subject: [PATCH 05/29] Agent: shared motion tokens, Tween and paint-offset primitives Document the motion rules in docs/motion.md. motion.rs gains the duration and easing tokens, a retargetable Tween that the hover plates and the sidebar collapse now share, Shifted (a layout-free paint offset), element-state Animated values, icon swaps and rotating chevrons. --- apps/maple-agent/CLAUDE.md | 9 + apps/maple-agent/app/src/ui/chat/mod.rs | 44 +- apps/maple-agent/app/src/ui/motion.rs | 628 ++++++++++++++++++++---- apps/maple-agent/docs/brand.md | 4 +- apps/maple-agent/docs/motion.md | 94 ++++ 5 files changed, 654 insertions(+), 125 deletions(-) create mode 100644 apps/maple-agent/docs/motion.md diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index a4c5c5458..084ed055c 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -118,6 +118,15 @@ popup before a nested one). The popup handles the rest: A text field inside a menu that goes away calls `Popup::refocus`, so the menu keeps the keyboard. Menu keys live in the keymap's `Menu` context. +## Motion + +Animate through `ui/motion.rs` only; [motion](docs/motion.md) holds the +tokens and rules. Motion never delays input, retargets from the value on +screen, closes menus and dialogs at once, and settles immediately under +reduced motion. Streaming text, typing, and keyboard navigation do not +animate. Rows inside `gpui::list` time their entrance from view state, not +element state, so they never replay when scrolled back into view. + ## Accessibility GPUI only reports elements with both an id and a role, and it builds the tree diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 0ae217fe6..8eb643313 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -117,25 +117,6 @@ 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.); /// Where the header's toolbar starts, from the window's left edge, as the @@ -631,7 +612,8 @@ pub struct ChatScreen { watched_git_dir: Option, /// Sidebar hidden; a toggle in the main pane brings it back. sidebar_collapsed: bool, - sidebar_transition: Option, + /// Collapse (0) or expand (1) under way; `None` at rest. + sidebar_transition: Option, /// Files staged for the next message. draft_attachments: Vec, unresolved_sends: HashMap, @@ -1100,7 +1082,7 @@ impl ChatScreen { if self.sidebar_collapsed == collapsed { return; } - let now = std::time::Instant::now(); + let now = crate::ui::motion::now(); let from = self .sidebar_transition .as_ref() @@ -1109,13 +1091,17 @@ impl ChatScreen { 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, + self.sidebar_transition = (!cx.reduce_motion() && distance > f32::EPSILON).then(|| { + let mut transition = crate::ui::motion::Tween::new(from); + transition.set( to, - started: now, - duration: SIDEBAR_TRANSITION_DURATION.mul_f32(distance), - }); + crate::ui::motion::PANEL.mul_f32(distance), + crate::ui::motion::ease_out_cubic, + now, + false, + ); + transition + }); if self.popup.is_open(&ChatPopup::Settings) { self.popup.close(cx); } @@ -1131,9 +1117,9 @@ impl ChatScreen { if cx.reduce_motion() { self.sidebar_transition = None; } - let now = std::time::Instant::now(); + let now = crate::ui::motion::now(); if let Some(transition) = &self.sidebar_transition { - if now.saturating_duration_since(transition.started) < transition.duration { + if transition.is_moving(now) { window.request_animation_frame(); return transition.value(now); } diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 3007b3ea5..1f57b3662 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -1,18 +1,23 @@ -//! Motion primitives that keep the frame rate low. +//! Motion primitives. The rules and tokens are in `docs/motion.md`. //! -//! gpui's own animation element asks for a frame on every display tick, -//! and each frame re-renders the whole window. Two patterns keep motion -//! cheap: +//! Every animated frame re-renders the window, so motion here is short +//! and asks for frames only while something moves: //! //! - Looping effects (spinners, pulsing dots) run through [`ticker`]: a //! shared phase clock plus one coalesced repaint per view per interval, //! at a rate the eye still reads as continuous (`LOOP_FPS`). -//! - One-shot effects (a popup fading in) use [`fade_in`], which wraps -//! gpui's oneshot `with_animation`: it requests frames only until the -//! animation ends, then the window goes idle again. +//! - One-shot entrances ([`fade_in`], [`rise_in`], [`slide_in`]) wrap +//! gpui's oneshot `with_animation`: they request frames until they end, +//! then the window goes idle again. A new element id replays them. +//! - Reversible state changes keep a [`Tween`], which retargets from the +//! value on screen: in view state for transitions a view owns (the +//! sidebar, the composer), or in element state through [`animated`] and +//! [`hover_background`] for render code that owns none. //! -//! Hover plates use a short, reversible color transition. Press feedback -//! remains immediate; no control scales or changes layout on hover. +//! Everything settles at its end state under reduced motion. gpui cannot +//! transform a `div`, so movement is a paint offset ([`Shifted`]) that +//! leaves layout alone; only SVG icons scale or rotate. Press feedback +//! stays immediate. use std::cell::RefCell; use std::collections::HashSet; @@ -21,15 +26,166 @@ use std::time::{Duration, Instant}; use gpui::{ AnimationElement, AnimationExt, AnyElement, App, Bounds, Element, ElementId, EntityId, - GlobalElementId, InspectorElementId, IntoElement, LayoutId, Pixels, Styled, Window, + GlobalElementId, InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, + Styled, Window, px, }; /// Frames per second a looping effect asks for. Twelve to fifteen steps a /// second still read as motion at a fraction of the display rate. pub const LOOP_FPS: u32 = 15; -/// Duration of a one-shot reveal. +/// Pointer hover: background and opacity only, the same both ways. +pub const HOVER: Duration = Duration::from_millis(120); +/// Menus, popovers, tooltips, notices, and content that replaces other +/// content (a Settings section, another task). pub const REVEAL: Duration = Duration::from_millis(140); +/// A control changing state in place: switches, chevrons, icon swaps. +pub const TOGGLE: Duration = Duration::from_millis(150); +/// Content arriving: live transcript rows, cards, dialogs. +pub const ENTER: Duration = Duration::from_millis(180); +/// Panels changing size: the sidebar and the expanded composer. +pub const PANEL: Duration = Duration::from_millis(200); +/// How far arriving content travels before it settles. +pub const RISE: Pixels = px(6.); + +/// Time for a box to grow or shrink by `distance`: about 500 px a second, +/// so one line of text takes the shortest time and a large jump [`PANEL`]. +pub fn resize_duration(distance: Pixels) -> Duration { + let millis = (distance.as_f32().abs() * 2.).clamp(120., PANEL.as_millis() as f32); + Duration::from_millis(millis as u64) +} + +/// Entrances and fades: a fast start and a long settle. +pub fn ease_out_quint(t: f32) -> f32 { + 1. - (1. - t).powi(5) +} + +/// Movement and size: gentler than [`ease_out_quint`], so a moving edge +/// stays readable. +pub fn ease_out_cubic(t: f32) -> f32 { + 1. - (1. - t).powi(3) +} + +/// Hover, which reverses often: the same curve both ways. +pub fn smoothstep(t: f32) -> f32 { + t * t * (3. - 2. * t) +} + +#[cfg(test)] +thread_local! { + static TEST_NOW: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// The clock transitions read. Tests pin it with [`set_test_now`], since +/// the test executor's clock does not advance `Instant`. +pub fn now() -> Instant { + #[cfg(test)] + if let Some(now) = TEST_NOW.with(std::cell::Cell::get) { + return now; + } + Instant::now() +} + +/// Pin [`now`] on this thread; `None` returns it to the real clock. +#[cfg(test)] +pub fn set_test_now(now: Option) { + TEST_NOW.with(|cell| cell.set(now)); +} + +/// One reversible value. Retargeting starts from the value on screen, so +/// a second click never jumps back to an endpoint or restarts. +#[derive(Clone, Copy, Debug)] +pub struct Tween { + from: f32, + to: f32, + started: Instant, + duration: Duration, + easing: fn(f32) -> f32, +} + +impl Tween { + /// At rest at `value`. + pub fn new(value: f32) -> Self { + Self { + from: value, + to: value, + started: epoch(), + duration: Duration::ZERO, + easing: ease_out_cubic, + } + } + + /// Where the value is heading. + pub fn target(&self) -> f32 { + self.to + } + + /// The value to draw at `now`. + pub fn value(&self, now: Instant) -> f32 { + let elapsed = now.saturating_duration_since(self.started); + if elapsed >= self.duration { + return self.to; + } + let t = elapsed.as_secs_f32() / self.duration.as_secs_f32(); + self.from + (self.to - self.from) * (self.easing)(t) + } + + /// Whether the value still changes after `now`. + pub fn is_moving(&self, now: Instant) -> bool { + now.saturating_duration_since(self.started) < self.duration + } + + /// Head for `to` over `duration` from wherever the value is now. + /// Reduced motion settles at once. + pub fn set( + &mut self, + to: f32, + duration: Duration, + easing: fn(f32) -> f32, + now: Instant, + reduced: bool, + ) { + if reduced || duration.is_zero() { + *self = Self::new(to); + } else if to != self.to { + *self = Self { + from: self.value(now), + to, + started: now, + duration, + easing, + }; + } + } + + /// [`Tween::set`], then the value to draw now and whether another + /// frame is needed. Reduced motion also settles a transition already + /// under way. + pub fn step( + &mut self, + to: f32, + duration: Duration, + easing: fn(f32) -> f32, + now: Instant, + reduced: bool, + ) -> (f32, bool) { + self.set(to, duration, easing, now, reduced); + if reduced { + *self = Self::new(self.to); + } + (self.value(now), self.is_moving(now)) + } +} + +/// How far an entrance that began at `started` is through `duration`, in +/// `[0, 1]`. For entrances timed by view state rather than element state, +/// such as transcript rows that can scroll away and back. +pub fn progress(started: Instant, duration: Duration, now: Instant, reduced: bool) -> f32 { + if reduced || duration.is_zero() { + return 1.; + } + (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.) +} /// Phase clock every looping effect reads, so they all stay in step and /// their frame timers line up. @@ -181,104 +337,365 @@ where { element.with_animation( id, - gpui::Animation::new(REVEAL).with_easing(gpui::ease_out_quint()), + gpui::Animation::new(REVEAL).with_easing(ease_out_quint), |element, delta| element.opacity(delta), ) } -/// Fade in and slide up a few pixels, for panels that open above their -/// anchor. Same one-shot cost profile as [`fade_in`]. -pub fn rise_in(element: E, id: impl Into) -> AnimationElement +/// Fade in and settle up from [`RISE`] below, for panels that open above +/// their anchor. Same one-shot cost profile as [`fade_in`]. +pub fn rise_in(element: E, id: impl Into) -> AnimationElement> where E: IntoElement + Styled + 'static, { - element.with_animation( + slide_in(element, id, REVEAL, gpui::point(px(0.), RISE)) +} + +/// Fade in over `duration` while moving from `from`, an offset from the +/// resting position, to rest. Layout and hit testing use the resting +/// position throughout. +pub fn slide_in( + element: E, + id: impl Into, + duration: Duration, + from: Point, +) -> AnimationElement> +where + E: IntoElement + Styled + 'static, +{ + shifted(element).with_animation( id, - gpui::Animation::new(REVEAL).with_easing(gpui::ease_out_quint()), - |element, delta| { + gpui::Animation::new(duration).with_easing(ease_out_quint), + move |element, delta| { + let remaining = 1. - delta; element .opacity(delta) - .mt(gpui::px(6. * (1. - delta))) - .mb(gpui::px(-6. * (1. - delta))) + .offset(gpui::point(from.x * remaining, from.y * remaining)) }, ) } -/// A sine pulse in `[low, high]` offset by `shift` cycles, for dots that -/// breathe out of step with each other. -pub fn pulse(phase: f32, shift: f32, low: f32, high: f32) -> f32 { - let wave = ((phase + shift) * std::f32::consts::TAU).sin() * 0.5 + 0.5; - low + (high - low) * wave +/// Draw content `progress` of the way through its entrance (see +/// [`progress`]): faded and [`RISE`] low at 0, at rest at 1. +pub fn arrive(element: E, progress: f32) -> Shifted +where + E: IntoElement + Styled + 'static, +{ + let eased = ease_out_quint(progress.clamp(0., 1.)); + shifted(element) + .opacity(eased) + .offset(gpui::point(px(0.), RISE * (1. - eased))) } -/// A short, symmetric transition for hover plates and control reveals. -/// Press and focus styles remain native GPUI refinements. -pub const HOVER: Duration = Duration::from_millis(120); +/// Paint an element away from where layout put it, hitbox included, +/// without moving anything else: gpui's stand-in for a CSS translate. +pub fn shifted(element: E) -> Shifted { + Shifted { + element: Some(element), + offset: Point::default(), + } +} -#[derive(Clone, Copy)] -struct HoverTransition { - from: f32, +pub struct Shifted { + element: Option, + offset: Point, +} + +impl Shifted { + /// Rounded to whole pixels, so shifted text stays crisp. + pub fn offset(mut self, offset: Point) -> Self { + self.offset = gpui::point(offset.x.round(), offset.y.round()); + self + } +} + +impl Styled for Shifted { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.element + .as_mut() + .expect("a shifted element is styled before layout") + .style() + } +} + +impl IntoElement for Shifted { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl Element for Shifted { + type RequestLayoutState = AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, AnyElement) { + let mut element = self + .element + .take() + .expect("a shifted element is laid out once") + .into_any_element(); + (element.request_layout(window, cx), element) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + window: &mut Window, + cx: &mut App, + ) { + window.with_element_offset(self.offset, |window| element.prepaint(window, cx)); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + element.paint(window, cx); + } +} + +/// An element rebuilt each frame from a value that eases toward `target` +/// and retargets from wherever it is. The value lives in element state +/// under `id`, so render code needs no view state. It starts at `target`, +/// so nothing animates on mount, unless [`Animated::enter_from`] says +/// otherwise. `build` runs at layout time; keep it cheap. +pub fn animated( + id: impl Into, target: f32, - started: Instant, + duration: Duration, + easing: fn(f32) -> f32, + build: impl Fn(f32) -> AnyElement + 'static, +) -> Animated { + Animated { + id: id.into(), + target, + duration, + easing, + initial: None, + build: Box::new(build), + } } -impl HoverTransition { - fn new(visible: bool, now: Instant) -> Self { - let value = if visible { 1. } else { 0. }; - Self { - from: value, - target: value, - started: now, - } +pub struct Animated { + id: ElementId, + target: f32, + duration: Duration, + easing: fn(f32) -> f32, + initial: Option, + build: Box AnyElement>, +} + +impl Animated { + /// Start from `value` the first time this element appears. + pub fn enter_from(mut self, value: f32) -> Self { + self.initial = Some(value); + self } +} + +// Keyed apart from other element state that may share the id. +struct AnimatedValue(Tween); + +impl IntoElement for Animated { + type Element = Self; - fn value(&self, now: Instant) -> f32 { - let t = (now.saturating_duration_since(self.started).as_secs_f32() / HOVER.as_secs_f32()) - .min(1.); - let eased = t * t * (3. - 2. * t); - self.from + (self.target - self.from) * eased + fn into_element(self) -> Self { + self } +} - fn update(&mut self, hovered: bool, now: Instant, reduced: bool) -> (f32, bool) { - let target = if hovered { 1. } else { 0. }; - if reduced { - self.from = target; - self.target = target; - } else if self.target != target { - self.from = self.value(now); - self.target = target; - self.started = now; +impl Element for Animated { + type RequestLayoutState = AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, AnyElement) { + let now = now(); + let reduced = cx.reduce_motion(); + let (value, moving) = window.with_element_state( + id.expect("animated elements have an id"), + |state: Option, _| { + let mut state = state.unwrap_or_else(|| { + AnimatedValue(Tween::new(self.initial.unwrap_or(self.target))) + }); + let frame = state + .0 + .step(self.target, self.duration, self.easing, now, reduced); + (frame, state) + }, + ); + if moving { + window.request_animation_frame(); } - let value = self.value(now); - (value, value != self.target) + let mut element = (self.build)(value); + (element.request_layout(window, cx), element) } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + window: &mut Window, + cx: &mut App, + ) { + element.prepaint(window, cx); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + element.paint(window, cx); + } +} + +/// Two icons sharing one `size` slot: the one `second` selects scales up +/// and fades in while the other scales down and out, over [`TOGGLE`]. +/// Use it where an icon names the state its control toggles (expand and +/// shrink, send and stop). +pub fn icon_swap( + id: impl Into, + second: bool, + icons: [&'static str; 2], + size: Pixels, + color: u32, +) -> gpui::Div { + let id = std::sync::Arc::new(id.into()); + let layer = + |index: usize| { + let name = icons[index]; + let shown = (index == 1) == second; + animated( + ElementId::NamedChild( + id.clone(), + if index == 0 { "first" } else { "second" }.into(), + ), + if shown { 1. } else { 0. }, + TOGGLE, + ease_out_cubic, + move |shown| { + let scale = 0.6 + 0.4 * shown; + gpui::div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .opacity(shown) + .child(super::icons::icon(name, size, color).with_transformation( + gpui::Transformation::scale(gpui::size(scale, scale)), + )) + .into_any_element() + }, + ) + }; + gpui::div() + .relative() + .flex_none() + .size(size) + .child(layer(0)) + .child(layer(1)) +} + +/// A disclosure chevron that points right when closed and turns down when +/// `open`, over [`TOGGLE`]. +pub fn chevron(id: impl Into, open: bool, size: Pixels, color: u32) -> Animated { + animated( + id, + if open { 1. } else { 0. }, + TOGGLE, + ease_out_cubic, + move |turn| { + super::icons::icon("chevron-right", size, color) + .with_transformation(gpui::Transformation::rotate(gpui::radians( + turn * std::f32::consts::FRAC_PI_2, + ))) + .into_any_element() + }, + ) +} + +/// A sine pulse in `[low, high]` offset by `shift` cycles, for dots that +/// breathe out of step with each other. +pub fn pulse(phase: f32, shift: f32, low: f32, high: f32) -> f32 { + let wave = ((phase + shift) * std::f32::consts::TAU).sin() * 0.5 + 0.5; + low + (high - low) * wave } -// Visibility and background hover can have different targets on one control. -struct RevealTransition(HoverTransition); +// Background and visibility on one control are separate values with +// separate targets under the same element id. +struct HoverPlate(Tween); +struct Reveal(Tween); -// Premultiplied RGB avoids a dark flash when fading from transparent. -fn hover_color(rest: gpui::Hsla, hover: gpui::Hsla, amount: f32) -> gpui::Hsla { - if amount == 0. { - return rest; +/// The color `amount` of the way from `from` to `to`. Premultiplied RGB +/// avoids a dark flash when fading from transparent. +pub fn mix(from: gpui::Hsla, to: gpui::Hsla, amount: f32) -> gpui::Hsla { + if amount <= 0. { + return from; } - if amount == 1. { - return hover; + if amount >= 1. { + return to; } - let rest = gpui::Rgba::from(rest); - let hover = gpui::Rgba::from(hover); - let alpha = rest.a + (hover.a - rest.a) * amount; + let from = gpui::Rgba::from(from); + let to = gpui::Rgba::from(to); + let alpha = from.a + (to.a - from.a) * amount; let channel = |a: f32, b: f32| { if alpha == 0. { 0. } else { - (a * rest.a * (1. - amount) + b * hover.a * amount) / alpha + (a * from.a * (1. - amount) + b * to.a * amount) / alpha } }; gpui::Rgba { - r: channel(rest.r, hover.r), - g: channel(rest.g, hover.g), - b: channel(rest.b, hover.b), + r: channel(from.r, to.r), + g: channel(from.g, to.g), + b: channel(from.b, to.b), a: alpha, } .into() @@ -426,26 +843,33 @@ impl Element for HoverBackground { .is_some_and(|hitbox| hitbox.is_hovered(window)) && !cx.has_active_drag() }); - let now = Instant::now(); + let now = now(); #[cfg(test)] let now = self.test_now.unwrap_or(now); + let reduced = cx.reduce_motion(); let (amount, animating) = window.with_element_state( id.expect("hover controls have an id"), - |state: Option, _| { - let mut state = state.unwrap_or_else(|| HoverTransition::new(false, now)); - let frame = state.update(hovered, now, cx.reduce_motion()); + |state: Option, _| { + let mut state = state.unwrap_or_else(|| HoverPlate(Tween::new(0.))); + let frame = state.0.step( + if hovered { 1. } else { 0. }, + HOVER, + smoothstep, + now, + reduced, + ); (frame, state) }, ); - self.element.style().background = Some(hover_color(self.rest, self.hover, amount).into()); + self.element.style().background = Some(mix(self.rest, self.hover, amount).into()); let reveal_animating = if let Some(visible) = self.revealed { + let target = if visible { 1. } else { 0. }; let (opacity, animating) = window.with_element_state( id.expect("revealed controls have an id"), - |state: Option, _| { + |state: Option, _| { // An initially visible control should not fade in on mount. - let mut state = state - .unwrap_or_else(|| RevealTransition(HoverTransition::new(visible, now))); - let frame = state.0.update(visible, now, cx.reduce_motion()); + let mut state = state.unwrap_or_else(|| Reveal(Tween::new(target))); + let frame = state.0.step(target, HOVER, smoothstep, now, reduced); (frame, state) }, ); @@ -496,44 +920,60 @@ impl Element for HoverBackground { #[cfg(test)] mod hover_tests { use super::*; + fn hover(tween: &mut Tween, hovered: bool, now: Instant, reduced: bool) -> (f32, bool) { + tween.step( + if hovered { 1. } else { 0. }, + HOVER, + smoothstep, + now, + reduced, + ) + } + #[test] fn hover_reverses_from_the_displayed_value_and_settles() { let now = Instant::now(); - let mut transition = HoverTransition::new(false, now); - assert_eq!(transition.update(false, now, false), (0., false)); - assert_eq!(transition.update(true, now, false), (0., true)); + let mut transition = Tween::new(0.); + assert_eq!(hover(&mut transition, false, now, false), (0., false)); + assert_eq!(hover(&mut transition, true, now, false), (0., true)); let halfway = now + HOVER / 2; - assert_eq!(transition.update(true, halfway, false), (0.5, true)); - assert_eq!(transition.update(false, halfway, false), (0.5, true)); + assert_eq!(hover(&mut transition, true, halfway, false), (0.5, true)); + assert_eq!(hover(&mut transition, false, halfway, false), (0.5, true)); assert_eq!( - transition.update(false, halfway + HOVER, false), + hover(&mut transition, false, halfway + HOVER, false), (0., false) ); assert_eq!( - transition.update(false, halfway + HOVER * 2, false), + hover(&mut transition, false, halfway + HOVER * 2, false), (0., false) ); } #[test] fn reduced_motion_settles_an_interrupted_hover_immediately() { let now = Instant::now(); - let mut transition = HoverTransition::new(false, now); - transition.update(true, now, false); - assert_eq!(transition.update(true, now + HOVER / 4, true), (1., false)); - assert_eq!(transition.update(false, now + HOVER / 2, true), (0., false)); + let mut transition = Tween::new(0.); + hover(&mut transition, true, now, false); + assert_eq!( + hover(&mut transition, true, now + HOVER / 4, true), + (1., false) + ); + assert_eq!( + hover(&mut transition, false, now + HOVER / 2, true), + (0., false) + ); } #[test] fn hover_eases_symmetrically_without_darkening_transparent_colors() { let now = Instant::now(); - let mut transition = HoverTransition::new(false, now); - transition.update(true, now, false); + let mut transition = Tween::new(0.); + hover(&mut transition, true, now, false); let early = transition.value(now + HOVER / 4); let late = transition.value(now + HOVER * 3 / 4); assert!(early < 0.25 && late > 0.75); assert!((early + late - 1.).abs() <= 2. * f32::EPSILON); let white = gpui::hsla(0., 0., 1., 1.); - let middle = gpui::Rgba::from(hover_color(gpui::transparent_black(), white, 0.5)); + let middle = gpui::Rgba::from(mix(gpui::transparent_black(), white, 0.5)); assert_eq!(middle.r, 1.); assert_eq!(middle.g, 1.); assert_eq!(middle.b, 1.); diff --git a/apps/maple-agent/docs/brand.md b/apps/maple-agent/docs/brand.md index 556d6a67f..4ae3c26fc 100644 --- a/apps/maple-agent/docs/brand.md +++ b/apps/maple-agent/docs/brand.md @@ -247,8 +247,8 @@ overlay). All share 8 px vertical and 20 px horizontal padding The coral fill replaces the kit's translucent "glass" gradient, which is designed to sit on a Pebble 300 panel and washes out on the app's white and near-black surfaces. The hover and press scale motion is not applied: -gpui has no transition primitive, and a stepped scale change on hover -reads as a flicker. +gpui cannot scale a `div`. Hover eases the background instead, and press +stays immediate; see [motion](motion.md). ### Spacing diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md new file mode 100644 index 000000000..37d87dbff --- /dev/null +++ b/apps/maple-agent/docs/motion.md @@ -0,0 +1,94 @@ +# Motion + +Maple Work moves only to explain a change: where something came from, what +state it is in now, or that the app heard the user. Motion never stands +between the user and the next action. This page records the shared decisions; +`app/src/ui/motion.rs` implements them. + +## Rules + +- **Purpose first.** Each motion shows feedback, a change of state, or where + something went. If removing it loses nothing, remove it. +- **Never delay input.** Content is clickable and typeable the moment it is + drawn. Sends, keyboard shortcuts, Escape and Stop act on the frame they + arrive; nothing waits for an animation to finish. +- **Interruptible.** Every transition that can reverse (hover, reveal, + switches, chevrons, the composer, the sidebar) retargets from the value on + screen. A second click never jumps back to an endpoint or restarts. +- **Fast in, faster out.** Entrances ease out. Menus, popovers and dialogs + close at once: dismissal is the user's decision and the next action must + not wait for it. Reversible state changes run the same curve both ways. +- **Opacity first, small distances.** gpui cannot scale or transform a `div`, + so content enters with opacity and at most a 6 px rise, drawn as a + relative offset that does not change layout. Only SVG icons scale or + rotate. Height animates only where a box truly changes size (text fields, + the expanded composer, the sidebar). +- **High-frequency work stays still.** Streaming tokens, typed characters, + keyboard navigation inside menus and lists, and history that loads or + scrolls into view do not animate. Rows animate once, when they arrive + live, never again when they scroll back into view. +- **No bounce, no stagger.** No overshoot or elastic curves anywhere; items + that arrive together appear together. +- **Reduced motion.** Settings → General → Reduce motion settles every + transition at its end state on the next frame and holds looping + indicators still. Every primitive in `motion.rs` honors it; new motion + must use those primitives rather than reading time itself. + +## Tokens + +| Token | Duration | Curve | Used for | +|---|---|---|---| +| `HOVER` | 120 ms | smoothstep | hover plates, controls that appear on hover | +| `REVEAL` | 140 ms | ease-out quint | menus, popovers, tooltips, notices, page and pane changes | +| `TOGGLE` | 150 ms | ease-out cubic | switches, checkboxes, chevrons, icon swaps, status changes | +| `ENTER` | 180 ms | ease-out quint | live transcript rows, cards above the composer, dialogs | +| `PANEL` | 200 ms | ease-out cubic | sidebar, composer expand and shrink | +| `resize_duration` | 120–200 ms | ease-out cubic | text fields growing and shrinking with their text, by distance | + +Press feedback is immediate (`.active()`), and focus rings appear at once. +Looping indicators (spinners, the waiting dots, the running ring) use the +shared 15 fps `ticker` clock and pause in background windows. + +## Where motion applies + +| Surface | In | Out | +|---|---|---| +| Hover on buttons, rows, menu items | background eases in, `HOVER` | eases back, `HOVER` | +| Menus, dropdowns, popovers | fade and 4 px slide away from the trigger, `REVEAL` | immediate | +| Tooltips | fade, `REVEAL`, after the existing delay | immediate | +| Dialogs and confirmations | scrim fades, card fades and rises, `ENTER` | immediate | +| Screen change (Chat ↔ Settings) and Settings sections | new content fades in, `REVEAL` | immediate | +| Switching tasks | transcript fades in when its snapshot lands, `REVEAL` | immediate | +| Live transcript rows (sent message, reply, tool card, error, stop notice) | fade and 6 px rise, `ENTER` | — | +| Streaming text | grows in place, no animation | — | +| Cards above the composer, queued messages, send preview | fade and rise, `ENTER` | immediate | +| Composer and multi-line fields | height eases to the new line count, `resize_duration` | same | +| Composer expand / shrink | height eases, `PANEL`; maximize and minimize icons cross-fade and scale, `TOGGLE` | same, reversed | +| Send ↔ Stop | icons cross-fade and scale, `TOGGLE` | same | +| Switches and checkboxes | knob slides, fill cross-fades, `TOGGLE` | same, reversed | +| Disclosure chevrons | rotate 90°, `TOGGLE` | same, reversed | +| Sidebar collapse | width eases, `PANEL` | same, reversed | +| Task status in the sidebar | indicators cross-fade, `TOGGLE` | same | + +Loading spinners for work that usually finishes quickly (opening a task) +appear only after 300 ms, so fast loads never flash a spinner. + +## Implementation + +- `Tween` is the one retargetable value: it starts from the value on screen + and settles under reduced motion. Views that own a transition (the sidebar, + the composer) keep a `Tween` in their state, sample it in `render`, and call + `window.request_animation_frame()` only while it moves. +- `animated(id, target, ...)` keeps a `Tween` in element state for render code + that owns no state (chevrons, switches, icon swaps). It does not animate on + mount unless asked with `enter_from`. +- `fade_in` and `rise_in` are one-shot entrances keyed by element id. A new + id replays them; use that for content swaps (a Settings section, a task). +- Transcript rows sit in a `gpui::list`, whose items lose element state when + they scroll away. Their entrance is driven by the arrival time kept in view + state, so a row animates once and never replays. +- Every animated frame re-renders the window. Keep animations short, request + frames only while something moves, and never animate inside cached views + without an element id that is stable across frames. +- Tests that assert settled layout opt into reduced motion + (`cx.set_reduce_motion(true)`); motion tests sample deterministic time. From c3e0980c2e9287069e4292cfe26039a38ac3cfef Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:21:59 -0800 Subject: [PATCH 06/29] Agent: cover animated values and paint offsets with tests Animated values start at rest, reverse from the value on screen, settle without asking for frames, and snap under reduced motion. A shifted element moves by whole pixels without moving its neighbors. --- apps/maple-agent/app/src/ui/motion.rs | 130 ++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 1f57b3662..630b90bcc 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -1214,3 +1214,133 @@ mod hover_tests { assert!(child_renders.get() > child_count); } } + +#[cfg(test)] +mod primitive_tests { + use super::*; + use gpui::{VisualTestContext, div, prelude::*}; + use std::{cell::Cell, rc::Rc}; + + struct Host { + open: bool, + shift: Pixels, + seen: Rc>, + } + + impl gpui::Render for Host { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let seen = self.seen.clone(); + div() + .size_full() + .flex() + .flex_col() + .child(animated( + "value", + if self.open { 1. } else { 0. }, + PANEL, + ease_out_cubic, + move |value| { + seen.set(value); + div().size(px(10.)).into_any_element() + }, + )) + .child( + shifted( + div() + .debug_selector(|| "shifted".into()) + .w(px(40.)) + .h(px(20.)), + ) + .offset(gpui::point(px(0.), self.shift)), + ) + .child( + div() + .debug_selector(|| "below".into()) + .w(px(40.)) + .h(px(20.)), + ) + } + } + + fn host( + cx: &mut gpui::TestAppContext, + ) -> (gpui::Entity, &mut VisualTestContext, Rc>) { + let seen = Rc::new(Cell::new(-1.)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (host, cx) = cx.add_window_view(|_, _| Host { + open: false, + shift: px(0.), + seen: seen.clone(), + }); + cx.run_until_parked(); + (host, cx, seen) + } + + #[gpui::test] + fn animated_values_start_at_rest_reverse_from_the_screen_and_settle( + cx: &mut gpui::TestAppContext, + ) { + let start = Instant::now(); + set_test_now(Some(start)); + let (host, cx, seen) = host(cx); + assert_eq!(seen.get(), 0., "nothing animates on mount"); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + + let draw = |at: Instant, open: bool, cx: &mut VisualTestContext| { + set_test_now(Some(at)); + host.update(cx, |host, cx| { + host.open = open; + cx.notify(); + }); + cx.run_until_parked(); + }; + draw(start, true, cx); + assert_eq!(seen.get(), 0.); + draw(start + PANEL / 2, true, cx); + let halfway = seen.get(); + assert!(halfway > 0.5 && halfway < 1., "eases out: {halfway}"); + draw(start + PANEL / 2, false, cx); + assert_eq!( + seen.get(), + halfway, + "reversing starts from the value on screen" + ); + draw(start + PANEL / 2 + PANEL / 4, false, cx); + assert!(seen.get() > 0. && seen.get() < halfway); + draw(start + PANEL * 2, false, cx); + assert_eq!(seen.get(), 0.); + // The frame requested while it moved still runs once. + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_eq!(seen.get(), 0.); + assert_eq!( + cx.update(|window, cx| window.simulate_next_frame(cx)), + 0, + "a settled value asks for no frames" + ); + + cx.update(|_, cx| cx.set_reduce_motion(true)); + draw(start + PANEL * 2, true, cx); + assert_eq!(seen.get(), 1., "reduced motion settles at once"); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + set_test_now(None); + } + + #[gpui::test] + fn shifted_content_moves_without_moving_its_neighbors(cx: &mut gpui::TestAppContext) { + let (host, cx, _) = host(cx); + let shifted = cx.debug_bounds("shifted").unwrap(); + let below = cx.debug_bounds("below").unwrap(); + host.update(cx, |host, cx| { + host.shift = px(5.6); + cx.notify(); + }); + cx.run_until_parked(); + let moved = cx.debug_bounds("shifted").unwrap(); + assert_eq!(moved.origin.y, shifted.origin.y + px(6.), "whole pixels"); + assert_eq!(moved.size, shifted.size); + assert_eq!(cx.debug_bounds("below").unwrap(), below); + } +} From 7000cc8714fa8c1f31797368d70b21f59e94b1d5 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:45:53 -0800 Subject: [PATCH 07/29] Agent: menus open from their trigger Menus and popovers fade in over REVEAL while settling 4 px away from the trigger: down below it, up above it, sideways for submenus, following a flipped placement. Each opening replays the entrance, closing stays immediate, and arrow-key highlight moves land at once while pointer hover still eases. Adds HoverBackground::instant and the swap_in content fade to motion.rs. --- apps/maple-agent/app/src/ui/chat/tests.rs | 2 + apps/maple-agent/app/src/ui/motion.rs | 127 +++++++++++++- apps/maple-agent/app/src/ui/popup.rs | 200 +++++++++++++++++++--- 3 files changed, 303 insertions(+), 26 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 32a29532d..527e628f1 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -8675,6 +8675,7 @@ mod state_tests { /// A press on an item still reaches the intended action. #[gpui::test] fn test_a_press_on_a_menu_item_reaches_the_item(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"), @@ -11955,6 +11956,7 @@ mod state_tests { #[gpui::test] fn test_composer_menu_floats_above_the_composer(cx: &mut TestAppContext) { cx.executor().allow_parking(); + cx.update(|cx| cx.set_reduce_motion(true)); struct ChatHost { chat: Entity, } diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 630b90bcc..62413806b 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -715,6 +715,7 @@ pub fn hover_background( hover: hover.into(), highlighted: None, revealed: None, + instant: false, #[cfg(test)] observed: None, #[cfg(test)] @@ -730,6 +731,7 @@ pub struct HoverBackground { hover: gpui::Hsla, highlighted: Option, revealed: Option, + instant: bool, #[cfg(test)] observed: Option>>, #[cfg(test)] @@ -759,6 +761,13 @@ impl HoverBackground { self.revealed = Some(visible); self } + + /// Settle at once instead of easing, for a highlight the keyboard + /// moved: arrow keys inside a menu must not trail behind. + pub fn instant(mut self, instant: bool) -> Self { + self.instant = instant; + self + } } impl Styled for HoverBackground { @@ -846,7 +855,7 @@ impl Element for HoverBackground { let now = now(); #[cfg(test)] let now = self.test_now.unwrap_or(now); - let reduced = cx.reduce_motion(); + let reduced = cx.reduce_motion() || self.instant; let (amount, animating) = window.with_element_state( id.expect("hover controls have an id"), |state: Option, _| { @@ -917,6 +926,122 @@ impl Element for HoverBackground { } } +// ---- Content swaps -------------------------------------------------------- + +/// Content that replaces other content in one place, such as the window's +/// screen or a Settings section: whenever `key` changes, the new content +/// fades in over [`REVEAL`]. Its first appearance does not fade, so a +/// first paint is never held back. `id` must be stable across frames; the +/// fade sets the element's opacity while it runs. +pub fn swap_in(element: E, id: impl Into, key: impl Into) -> Swap +where + E: IntoElement + Styled + 'static, +{ + Swap { + id: id.into(), + key: key.into(), + element: Some(element), + } +} + +pub struct Swap { + id: ElementId, + key: ElementId, + element: Option, +} + +// The key last drawn, and when content under a new key arrived. +struct Swapped { + key: ElementId, + started: Option, +} + +impl IntoElement for Swap { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl Element for Swap { + type RequestLayoutState = AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, AnyElement) { + let now = now(); + let reduced = cx.reduce_motion(); + let key = &self.key; + let opacity = window.with_element_state( + id.expect("swaps have an id"), + |state: Option, _| { + let mut state = state.unwrap_or_else(|| Swapped { + key: key.clone(), + started: None, + }); + if &state.key != key { + state.key = key.clone(); + state.started = Some(now); + } + let opacity = state.started.map_or(1., |started| { + ease_out_quint(progress(started, REVEAL, now, reduced)) + }); + if opacity >= 1. { + state.started = None; + } + (opacity, state) + }, + ); + let element = self.element.take().expect("a swap is laid out once"); + let mut element = if opacity < 1. { + window.request_animation_frame(); + element.opacity(opacity).into_any_element() + } else { + element.into_any_element() + }; + (element.request_layout(window, cx), element) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + window: &mut Window, + cx: &mut App, + ) { + element.prepaint(window, cx); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + element.paint(window, cx); + } +} + #[cfg(test)] mod hover_tests { use super::*; diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index f39191b64..fbdb0adb6 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -73,6 +73,8 @@ gpui::actions!( /// Space kept between a trigger and the menu it opens. const GAP: Pixels = px(4.); +/// How far a menu travels as it opens, settling away from its trigger. +const SLIDE: Pixels = px(4.); /// Closest a menu comes to the window edge. const WINDOW_MARGIN: Pixels = px(8.); @@ -178,6 +180,11 @@ pub(crate) struct Popup { /// menu is open (a status that loads late) do not move it to another /// item. highlighted: Option, + /// The keyboard moved the highlight last, so it moves at once; the + /// pointer's highlight eases like any hover. + keyboard: bool, + /// Counts openings, so each opening replays the entrance. + opened: u64, focus: FocusHandle, /// Focus moves in on the next render, once the panel is in the tree. focus_pending: bool, @@ -205,6 +212,8 @@ impl Popup { get, open: None, highlighted: None, + keyboard: false, + opened: 0, focus: cx.focus_handle(), focus_pending: false, focus_out: None, @@ -262,6 +271,7 @@ impl Popup { *self.hover_opener.get_mut() = None; if self.open.as_ref() != Some(&key) { self.scroll.set_offset(point(px(0.), px(0.))); + self.opened += 1; } self.open = Some(key); self.reveal.set(if item.is_some() { @@ -269,6 +279,7 @@ impl Popup { } else { Reveal::Done }); + self.keyboard = item.is_some(); self.highlighted = item; self.focus_pending = true; cx.notify(); @@ -579,7 +590,7 @@ impl Popup { initial: initial(&item.label), child: children.len(), }); - children.push(item_row(item, highlighted, get, cx)); + children.push(item_row(item, highlighted, self.keyboard, get, cx)); } Entry::Header(text) => children.push( div() @@ -733,7 +744,11 @@ impl Popup { .size_full(), ); Float { - panel: Some(motion::fade_in(panel, "popup-reveal").into_any_element()), + id: ElementId::NamedChild( + std::sync::Arc::new(id), + SharedString::from(format!("opened-{}", self.opened)), + ), + panel: Some(panel), placement, priority, } @@ -786,16 +801,18 @@ pub(crate) enum Placement { impl Placement { /// The top-left corner of a panel of `size` in a window of `window` - /// size. The panel goes on its preferred side of the trigger (or of the - /// point) when it fits there, on the other side when only that one has - /// room, and otherwise on the roomier side, pushed inside the window. + /// size, and the offset from there it opens from: [`SLIDE`] toward the + /// trigger, so it settles away from it. The panel goes on its preferred + /// side of the trigger (or of the point) when it fits there, on the + /// other side when only that one has room, and otherwise on the roomier + /// side, pushed inside the window. fn origin( self, trigger: Bounds, size: Size, window: Size, margin: Pixels, - ) -> Point { + ) -> (Point, Point) { let (anchor, prefer_below, prefer_start, gap) = match self { Placement::BelowStart => (trigger, true, true, GAP), Placement::BelowEnd => (trigger, true, false, GAP), @@ -826,7 +843,8 @@ impl Placement { let y = y.min(window.height - margin - size.height).max(margin); // Whole pixels, rounding away from the trigger to keep the gap. let y = if below { y.ceil() } else { y.floor() }; - point(x.round(), y) + let from = point(px(0.), if below { -SLIDE } else { SLIDE }); + (point(x.round(), y), from) } } @@ -835,10 +853,11 @@ fn beside( size: Size, window: Size, margin: Pixels, -) -> Point { +) -> (Point, Point) { let room_right = window.width - margin - trigger.right() - GAP; let room_left = trigger.left() - GAP - margin; - let x = if first_side(true, size.width, room_right, room_left) { + let right = first_side(true, size.width, room_right, room_left); + let x = if right { trigger.right() + GAP } else { trigger.left() - GAP - size.width @@ -848,7 +867,8 @@ fn beside( .top() .min(window.height - margin - size.height) .max(margin); - point(x.round(), y.round()) + let from = point(if right { -SLIDE } else { SLIDE }, px(0.)); + (point(x.round(), y.round()), from) } /// Whether a panel `length` long goes on the first of two sides, which @@ -868,13 +888,20 @@ fn first_side(prefer_first: bool, length: Pixels, first: Pixels, second: Pixels) /// Draws a menu panel above everything, where [`Placement::origin`] puts /// it. Beside a trigger, this element covers the `relative` box around the /// trigger and takes that box's bounds. The panel is measured before it is -/// placed, so it lands on the right side in its first frame. +/// placed, so it lands on the right side in its first frame, and it opens +/// from that side: it fades in while settling [`SLIDE`] away from the +/// trigger. `id` is new for each opening, so the entrance replays when one +/// menu replaces another. struct Float { - panel: Option, + id: ElementId, + panel: Option
, placement: Placement, priority: usize, } +/// When the panel under a [`Float`] id first drew. +struct Opened(std::time::Instant); + impl IntoElement for Float { type Element = Self; @@ -888,7 +915,7 @@ impl Element for Float { type PrepaintState = (); fn id(&self) -> Option { - None + Some(self.id.clone()) } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { @@ -914,22 +941,46 @@ impl Element for Float { fn prepaint( &mut self, - _: Option<&GlobalElementId>, + id: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut (), window: &mut Window, cx: &mut App, ) { - let Some(mut panel) = self.panel.take() else { + let Some(panel) = self.panel.take() else { return; }; + // Opacity does not reach into a deferred draw, so the entrance is + // drawn here, on the panel itself. + let now = motion::now(); + let started = window.with_element_state( + id.expect("a float has an id"), + |state: Option, _| { + let started = state.map_or(now, |state| state.0); + (started, Opened(started)) + }, + ); + let shown = motion::ease_out_quint(motion::progress( + started, + motion::REVEAL, + now, + cx.reduce_motion(), + )); + let mut panel = if shown < 1. { + window.request_animation_frame(); + panel.opacity(shown).into_any_element() + } else { + panel.into_any_element() + }; let size = panel.layout_as_root(AvailableSpace::min_size(), window, cx); let margin = window_margin(window); - let origin = self + let (origin, from) = self .placement .origin(bounds, size, window.viewport_size(), margin); - window.defer_draw(panel, origin, self.priority, None); + let rest = 1. - shown; + let offset = point((from.x * rest).round(), (from.y * rest).round()); + window.defer_draw(panel, origin + offset, self.priority, None); } fn paint( @@ -1201,6 +1252,7 @@ fn initial(text: &str) -> Option { fn item_row( item: MenuItem, highlighted: bool, + keyboard: bool, get: fn(&mut V) -> &mut Popup, cx: &mut Context, ) -> AnyElement { @@ -1268,6 +1320,7 @@ fn item_row( close(view, window, cx); } let popup = get(view); + popup.keyboard = false; if *hovered { popup.hover = None; popup.highlight(enabled.then(|| hovered_id.clone()), cx); @@ -1394,6 +1447,7 @@ fn item_row( gpui::rgb(theme::bg_sidebar_row_hover()), ) .highlighted(highlighted) + .instant(keyboard) .into_any_element() } @@ -1430,6 +1484,7 @@ fn step( let candidate = &picks[at as usize]; if candidate.enabled { popup.scroll.scroll_to_item(candidate.child); + popup.keyboard = true; popup.highlight(Some(candidate.id.clone()), cx); return; } @@ -1488,6 +1543,7 @@ fn type_ahead( .find(|&row| picks[row].enabled && typed.is_some() && picks[row].initial == typed); if let Some(row) = found { popup.scroll.scroll_to_item(picks[row].child); + popup.keyboard = true; popup.highlight(Some(picks[row].id.clone()), cx); } } @@ -2100,6 +2156,96 @@ mod tests { assert_eq!(open(&host, cx), None); } + /// A menu opens from its trigger: below one it settles down, above + /// one it settles up, and once there it asks for no more frames. A + /// menu that replaces another opens the same way, and reduced motion + /// draws it in place. + #[gpui::test] + fn a_menu_slides_in_away_from_its_trigger(cx: &mut TestAppContext) { + let start = std::time::Instant::now(); + motion::set_test_now(Some(start)); + let (_host, cx) = setup(cx); + cx.update(|_, cx| cx.set_reduce_motion(false)); + let frames = + |cx: &mut VisualTestContext| cx.update(|window, cx| window.simulate_next_frame(cx)); + let frame_at = |at: std::time::Instant, cx: &mut VisualTestContext| { + motion::set_test_now(Some(at)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + }; + let top = |menu: &'static str, cx: &mut VisualTestContext| { + cx.debug_bounds(menu).expect("menu").top() + }; + + click(cx, "b-trigger"); + let arriving = top("menu-b", cx); + frame_at(start + motion::REVEAL, cx); + assert_eq!( + top("menu-b", cx) - arriving, + SLIDE, + "below, it settles down" + ); + assert_eq!(frames(cx), 0, "an open menu at rest asks for no frames"); + + click(cx, "a-trigger"); + let arriving = top("menu-a", cx); + frame_at(start + motion::REVEAL * 2, cx); + let rest = top("menu-a", cx); + assert_eq!(rest - arriving, SLIDE, "the next menu opens the same way"); + + click(cx, "low-trigger"); + let arriving = top("menu-low", cx); + frame_at(start + motion::REVEAL * 3, cx); + assert_eq!( + arriving - top("menu-low", cx), + SLIDE, + "above, it settles up" + ); + assert_eq!(frames(cx), 0); + + click(cx, "low-trigger"); + cx.update(|_, cx| cx.set_reduce_motion(true)); + click(cx, "a-trigger"); + assert_eq!(top("menu-a", cx), rest, "reduced motion opens at rest"); + assert_eq!(frames(cx), 0); + motion::set_test_now(None); + } + + /// Arrow keys move the highlight on the frame they arrive; the + /// pointer's highlight eases in like any hover. + #[gpui::test] + fn the_keyboard_moves_the_highlight_at_once(cx: &mut TestAppContext) { + let start = std::time::Instant::now(); + motion::set_test_now(Some(start)); + let (host, cx) = setup(cx); + cx.update(|_, cx| cx.set_reduce_motion(false)); + let frames = + |cx: &mut VisualTestContext| cx.update(|window, cx| window.simulate_next_frame(cx)); + host.update(cx, |host, cx| host.popup.open(Which::A, cx)); + cx.run_until_parked(); + motion::set_test_now(Some(start + motion::REVEAL)); + frames(cx); + cx.run_until_parked(); + assert_eq!(frames(cx), 0, "the menu has arrived"); + + cx.simulate_keystrokes("down"); + assert_eq!(highlighted(&host, cx), Some("a-one".into())); + assert_eq!( + frames(cx), + 0, + "a highlight the keyboard moved does not ease" + ); + cx.simulate_keystrokes("down"); + assert_eq!(frames(cx), 0); + let row = cx.debug_bounds("a-three").expect("row"); + cx.simulate_mouse_move(row.center(), None, Modifiers::default()); + assert_eq!(highlighted(&host, cx), Some("a-three".into())); + assert!(frames(cx) > 0, "the pointer's highlight eases"); + motion::set_test_now(None); + } + #[gpui::test] fn a_menu_opened_on_a_row_shows_that_row(cx: &mut TestAppContext) { let (host, cx) = setup(cx); @@ -2124,6 +2270,7 @@ mod tests { #[gpui::test] fn a_menu_larger_than_the_window_fits_inside_it(cx: &mut TestAppContext) { let (host, cx) = setup(cx); + cx.update(|_, cx| cx.set_reduce_motion(true)); // Narrower and shorter than menu A. let window = size(px(140.), px(100.)); cx.simulate_resize(window); @@ -2157,8 +2304,9 @@ mod tests { let window = size(px(800.), px(600.)); let panel = size(px(200.), px(300.)); let trigger = |x: f32, y: f32| Bounds::new(point(px(x), px(y)), size(px(80.), px(20.))); - let origin = - |placement: Placement, trigger| placement.origin(trigger, panel, window, WINDOW_MARGIN); + let origin = |placement: Placement, trigger| { + placement.origin(trigger, panel, window, WINDOW_MARGIN).0 + }; // Room below: below, 4 px down, left edges shared. assert_eq!( origin(Placement::BelowStart, trigger(100., 100.)), @@ -2181,12 +2329,14 @@ mod tests { ); // Room on neither side: the roomier side, inside the window. assert_eq!( - Placement::BelowStart.origin( - trigger(100., 250.), - size(px(200.), px(560.)), - window, - WINDOW_MARGIN - ), + Placement::BelowStart + .origin( + trigger(100., 250.), + size(px(200.), px(560.)), + window, + WINDOW_MARGIN + ) + .0, point(px(100.), px(32.)) ); // A right-click near the bottom right corner opens up and left. From 359b45051cdb5bdaa4c44c49fa60cf8abcdab6ea Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:45:53 -0800 Subject: [PATCH 08/29] Agent: switches slide, dialogs rise, tooltips fade Switch knobs slide and tracks cross-fade over TOGGLE, reversing from the knob's position; checkbox fills cross-fade and the check scales in. Dialog scrims fade and cards rise 8 px over ENTER and close at once. Tooltips fade in over REVEAL after their delay. --- apps/maple-agent/app/src/ui/chat/dialogs.rs | 12 +- apps/maple-agent/app/src/ui/chat/feedback.rs | 6 +- .../app/src/ui/chat/project_folder.rs | 6 +- .../app/src/ui/update_confirmation.rs | 39 ++- apps/maple-agent/app/src/ui/widgets.rs | 235 ++++++++++++++---- 5 files changed, 239 insertions(+), 59 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index 42557cbd7..1ffabb403 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -134,11 +134,11 @@ impl ChatScreen { .top_0() .left_0() .occlude() - .bg(theme::scrim()) .flex() .items_center() .justify_center() - .child( + .child(widgets::dialog_scrim()) + .child(widgets::dialog_card( div() .id("trust-card") .role(gpui::Role::Dialog) @@ -219,7 +219,7 @@ impl ChatScreen { ), ), ), - ) + )) } /// Ask before a project leaves the sidebar. @@ -323,7 +323,6 @@ impl ChatScreen { .top_0() .left_0() .occlude() - .bg(theme::scrim()) .flex() .items_center() .justify_center() @@ -331,7 +330,8 @@ impl ChatScreen { this.confirm = None; cx.notify(); })) - .child( + .child(widgets::dialog_scrim()) + .child(widgets::dialog_card( div() .id("confirm-card") .role(gpui::Role::Dialog) @@ -407,7 +407,7 @@ impl ChatScreen { }), )), ), - ) + )) } /// Drop the selection and everything the composer shows for it: the diff --git a/apps/maple-agent/app/src/ui/chat/feedback.rs b/apps/maple-agent/app/src/ui/chat/feedback.rs index e92c2d128..5674ddc6d 100644 --- a/apps/maple-agent/app/src/ui/chat/feedback.rs +++ b/apps/maple-agent/app/src/ui/chat/feedback.rs @@ -224,12 +224,12 @@ impl ChatScreen { .absolute() .inset_0() .occlude() - .bg(theme::scrim()) .flex() .items_center() .justify_center() .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child( + .child(widgets::dialog_scrim()) + .child(widgets::dialog_card( div() .id("feedback-form") .role(gpui::Role::Dialog) @@ -346,7 +346,7 @@ impl ChatScreen { ), ), ), - ), + )), ) } } diff --git a/apps/maple-agent/app/src/ui/chat/project_folder.rs b/apps/maple-agent/app/src/ui/chat/project_folder.rs index b0926be3f..f291efe6c 100644 --- a/apps/maple-agent/app/src/ui/chat/project_folder.rs +++ b/apps/maple-agent/app/src/ui/chat/project_folder.rs @@ -644,11 +644,11 @@ impl Render for ProjectFolderDialog { .inset_0() .size_full() .occlude() - .bg(theme::scrim()) .flex() .items_center() .justify_center() - .child( + .child(widgets::dialog_scrim()) + .child(widgets::dialog_card( div() .id("project-folder-dialog") .debug_selector(|| "project-folder-dialog".into()) @@ -959,7 +959,7 @@ impl Render for ProjectFolderDialog { ), ), ), - ) + )) } } diff --git a/apps/maple-agent/app/src/ui/update_confirmation.rs b/apps/maple-agent/app/src/ui/update_confirmation.rs index c077a03b3..7f36cedf4 100644 --- a/apps/maple-agent/app/src/ui/update_confirmation.rs +++ b/apps/maple-agent/app/src/ui/update_confirmation.rs @@ -86,11 +86,11 @@ impl Render for UpdateConfirmation { .inset_0() .size_full() .occlude() - .bg(theme::scrim()) .flex() .items_center() .justify_center() - .child( + .child(widgets::dialog_scrim()) + .child(widgets::dialog_card( div() .id("update-confirmation") .debug_selector(|| "update-confirmation".to_string()) @@ -177,7 +177,7 @@ impl Render for UpdateConfirmation { })), ), ), - ) + )) .into_any_element() } } @@ -342,6 +342,39 @@ mod tests { assert_eq!(answer.try_recv().unwrap(), 1); } + /// The card rises into place as the dialog opens (the test executor + /// draws its first frames well inside the entrance), at rest under + /// reduced motion, and leaves on the frame that answers it. + #[gpui::test] + fn the_dialog_rises_in_and_leaves_at_once(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(false)); + let (_host, mut answer, cx) = show(cx); + cx.run_until_parked(); + let arriving = cx.debug_bounds("update-confirmation").unwrap(); + cx.update(|_, cx| cx.set_reduce_motion(true)); + cx.run_until_parked(); + let rest = cx.debug_bounds("update-confirmation").unwrap(); + let rise = arriving.top() - rest.top(); + assert!( + px(0.) < rise && rise <= px(8.), + "the card rises from up to 8 px below: {arriving:?} -> {rest:?}" + ); + assert_eq!(arriving.size, rest.size); + // Frames the entrance asked for before reduced motion came on. + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + + cx.simulate_keystrokes("escape"); + assert_eq!(answer.try_recv().unwrap(), 0); + assert!( + cx.debug_bounds("update-confirmation").is_none(), + "the dialog leaves on the frame it closes" + ); + } + #[gpui::test] fn dropping_the_updater_confirmation_cancels_without_an_answer(cx: &mut gpui::TestAppContext) { let (host, mut answer, cx) = show(cx); diff --git a/apps/maple-agent/app/src/ui/widgets.rs b/apps/maple-agent/app/src/ui/widgets.rs index 8b1c7c0d7..005da7261 100644 --- a/apps/maple-agent/app/src/ui/widgets.rs +++ b/apps/maple-agent/app/src/ui/widgets.rs @@ -19,7 +19,7 @@ use gpui::{ }; use super::icons::icon; -use super::motion::{HoverBackground, hover_background}; +use super::motion::{self, HoverBackground, hover_background}; use super::theme; /// How long an icon-only control waits before showing its tooltip. Icons @@ -226,12 +226,17 @@ pub fn switch(id: impl Into, on: bool) -> Stateful
{ /// A labeled checkbox row. The caller supplies its tab index and click/key /// handlers, so mouse and keyboard activation share the same state change. +/// The box's fill cross-fades and the check scales in over +/// [`motion::TOGGLE`]. pub fn checkbox( id: impl Into, checked: bool, label: impl Into, ) -> Stateful
{ let label = label.into(); + let unchecked_border: Hsla = gpui::rgb(theme::text_muted()).into(); + let unchecked_fill: Hsla = gpui::rgb(theme::bg_input()).into(); + let accent = accent(); div() .id(id) .role(Role::CheckBox) @@ -251,56 +256,81 @@ pub fn checkbox( .focus_visible(focus_ring) .hover(|style| style.cursor_pointer()) .active(|style| style.opacity(0.85)) - .child( - div() - .flex_none() - .size(px(18.)) - .rounded(px(4.)) - .border_1() - .border_color(gpui::rgb(if checked { - theme::accent() - } else { - theme::text_muted() - })) - .bg(gpui::rgb(if checked { - theme::accent() - } else { - theme::bg_input() - })) - .flex() - .items_center() - .justify_center() - .when(checked, |square| { - square.child(icon("check", px(14.), theme::on_accent())) - }), - ) + .child(motion::animated( + "checkbox-mark", + if checked { 1. } else { 0. }, + motion::TOGGLE, + motion::ease_out_cubic, + move |checked| { + let scale = 0.6 + 0.4 * checked; + div() + .flex_none() + .size(px(18.)) + .rounded(px(4.)) + .border_1() + .border_color(motion::mix(unchecked_border, accent, checked)) + .bg(motion::mix(unchecked_fill, accent, checked)) + .flex() + .items_center() + .justify_center() + .when(checked > 0., |square| { + square.child(div().flex().opacity(checked).child( + icon("check", px(14.), theme::on_accent()).with_transformation( + gpui::Transformation::scale(gpui::size(scale, scale)), + ), + )) + }) + .into_any_element() + }, + )) .child(label) } +/// How far a [`switch`] knob travels: the track's width, less its padding +/// and the knob. +const KNOB_TRAVEL: Pixels = px(34. - 2. * 3. - 14.); + /// The look of a [`switch`] without its role or input, for a row that is -/// itself the control, such as a switch item in a popup menu. +/// itself the control, such as a switch item in a popup menu. Its knob +/// slides and its track cross-fades over [`motion::TOGGLE`], reversing +/// from wherever it is; it is still when it first appears. The enclosing +/// element's id keys the motion. pub fn switch_track(on: bool) -> Div { + let off_track: Hsla = gpui::rgb(theme::bg_sidebar_row_selected()).into(); + let on_track = accent(); + let knob: Hsla = gpui::rgb(theme::on_accent()).into(); div() .flex_none() .w(px(34.)) .h(px(20.)) - .p(px(3.)) .rounded_full() - .bg(gpui::rgb(if on { - theme::accent() - } else { - theme::bg_sidebar_row_selected() - })) - .flex() - .items_center() - .when(on, |track| track.justify_end()) - .child( - div() - .size(px(14.)) - .rounded_full() - .bg(gpui::rgb(theme::on_accent())) - .shadow_2xs(), - ) + .child(motion::animated( + "switch-knob", + if on { 1. } else { 0. }, + motion::TOGGLE, + motion::ease_out_cubic, + move |on| { + div() + .size_full() + .p(px(3.)) + .rounded_full() + .bg(motion::mix(off_track, on_track, on)) + .flex() + .items_center() + .child( + motion::shifted( + div() + .debug_selector(|| "switch-knob".into()) + .size(px(14.)) + .rounded_full() + .bg(knob) + .shadow_2xs(), + ) + .offset(gpui::point(KNOB_TRAVEL * on, px(0.))), + ) + .into_any_element() + }, + )) } /// Small status pill beside a card's title (an integration's availability, @@ -422,10 +452,45 @@ pub fn notice( /// Icon size in a popup menu row or an [`icon_button`] in a list row. pub const ROW_ICON: Pixels = px(14.); +// ---- Dialogs -------------------------------------------------------------- + +/// How far a dialog card rises as it opens. +const DIALOG_RISE: Pixels = px(8.); + +/// The dimmed layer behind a modal card. Put it first inside the dialog's +/// full-window backdrop, which keeps the pointer handling and centers the +/// card: the scrim fades in over [`motion::ENTER`] on its own, so the +/// card's fade is not multiplied by it. A dialog closes at once. +pub fn dialog_scrim() -> impl IntoElement { + use gpui::AnimationExt as _; + div() + .absolute() + .inset_0() + .bg(theme::scrim()) + .with_animation( + "dialog-scrim", + gpui::Animation::new(motion::ENTER).with_easing(motion::ease_out_quint), + |scrim, shown| scrim.opacity(shown), + ) +} + +/// A modal card arriving: it fades in and rises from 8 px below over +/// [`motion::ENTER`]. Layout uses its resting place throughout, and it +/// takes keys and focus from its first frame. +pub fn dialog_card(card: E) -> impl IntoElement { + motion::slide_in( + card, + "dialog-card", + motion::ENTER, + gpui::point(px(0.), DIALOG_RISE), + ) +} + // ---- Tooltips ------------------------------------------------------------- /// Small label shown above the pointer after gpui's hover delay, with an -/// optional fixed shortcut hint. Attach with `.tooltip(widgets::tooltip(..))`. +/// optional fixed shortcut hint. It fades in over [`motion::REVEAL`] and +/// goes at once. Attach with `.tooltip(widgets::tooltip(..))`. /// Prefer [`tooltip_for_action`] when the control fires an action: that /// reads the live keymap instead of a literal that a rebind would outdate. pub fn tooltip( @@ -551,7 +616,7 @@ const TOOLTIP_GAP: Pixels = px(16.); const TOOLTIP_CURSOR_CLEARANCE: Pixels = px(20.); impl Render for Tooltip { - fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { // gpui puts a tooltip's corner just past the pointer, where the // cursor covers it. The view stays zero-sized there and anchors the // label at the pointer itself: centered, 16 px above it. @@ -592,7 +657,10 @@ impl Render for Tooltip { div() .pt(TOOLTIP_CURSOR_CLEARANCE) .pb(TOOLTIP_GAP) - .child(label), + .child(motion::fade_in( + label, + ElementId::NamedInteger("tooltip".into(), cx.entity_id().as_u64()), + )), ), ) } @@ -826,4 +894,83 @@ mod tests { ); } } + + /// A lone switch and checkbox the test turns on and off. + struct Toggles { + on: bool, + } + + impl Render for Toggles { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl IntoElement { + div() + .size_full() + .p(px(20.)) + .child(switch("test-switch", self.on)) + .child(checkbox("test-checkbox", self.on, "Check")) + } + } + + /// A switch turned back mid-flight reverses from where its knob is + /// instead of jumping to an end, settles, and then asks for no more + /// frames. It is still when it first appears, and reduced motion moves + /// it at once. + #[gpui::test] + fn a_switch_reversed_mid_flight_turns_back_from_its_knob(cx: &mut TestAppContext) { + let start = Instant::now(); + motion::set_test_now(Some(start)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (host, cx) = cx.add_window_view(|_, _| Toggles { on: false }); + cx.run_until_parked(); + let knob = + |cx: &mut VisualTestContext| cx.debug_bounds("switch-knob").expect("knob").left(); + let frames = + |cx: &mut VisualTestContext| cx.update(|window, cx| window.simulate_next_frame(cx)); + let frame_at = |at: Instant, cx: &mut VisualTestContext| { + motion::set_test_now(Some(at)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + }; + let turn = |on: bool, cx: &mut VisualTestContext| { + host.update(cx, |host, cx| { + host.on = on; + cx.notify(); + }); + cx.run_until_parked(); + }; + let off = knob(cx); + assert_eq!(frames(cx), 0, "a switch is still when it first appears"); + + turn(true, cx); + assert_eq!(knob(cx), off, "the knob sets out from where it is"); + frame_at(start + motion::TOGGLE / 3, cx); + let partway = knob(cx); + assert!( + off < partway && partway < off + KNOB_TRAVEL, + "the knob {partway:?} is on its way from {off:?}" + ); + + turn(false, cx); + assert_eq!(knob(cx), partway, "turning back starts from the knob"); + frame_at(start + motion::TOGGLE / 3 + motion::TOGGLE / 4, cx); + let returning = knob(cx); + assert!( + off < returning && returning < partway, + "the knob {returning:?} heads back from {partway:?}" + ); + frame_at(start + motion::TOGGLE * 2, cx); + assert_eq!(knob(cx), off); + assert_eq!(frames(cx), 0, "a settled switch asks for no frames"); + + cx.update(|_, cx| cx.set_reduce_motion(true)); + turn(true, cx); + assert_eq!(knob(cx), off + KNOB_TRAVEL, "reduced motion moves at once"); + assert_eq!(frames(cx), 0); + motion::set_test_now(None); + } } From c9fd2c955a22d454afb2a4da7749101c0ec8c68b Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:45:53 -0800 Subject: [PATCH 09/29] Agent: screens and Settings sections fade in A new screen (Chat, Settings, sign-in, gates) fades in over REVEAL; the first screen draws at once. Settings panes fade on a section change, and on/off pills and the transport segments ease their selected fill. --- apps/maple-agent/app/src/desktop.rs | 20 +++++- apps/maple-agent/app/src/ui/settings.rs | 86 +++++++++++++++++++------ 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 8c9df8a1c..31a3fd4b2 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -2364,6 +2364,20 @@ impl Render for MapleApp { cx.refresh_windows(); } let titlebar = self.titlebar.clone(); + // The screen fades in when it changes kind; the first screen and a + // screen that only re-renders draw at once. + let screen_kind = if self.updater.restart_path().is_some() { + "restart" + } else { + match &self.screen { + Screen::Restoring => "restoring", + Screen::StoreGate(_) => "store-gate", + Screen::Login(_) => "login", + Screen::Chat(_) => "chat", + Screen::Settings(_) => "settings", + Screen::PlanGate(_) => "plan-gate", + } + }; let root = div() .relative() .size_full() @@ -2378,7 +2392,7 @@ impl Render for MapleApp { // on transcript/composer via `typography::chat_reading`. .text_sm() .child(titlebar) - .child( + .child(ui::motion::swap_in( div() .flex_1() .min_h_0() @@ -2423,7 +2437,9 @@ impl Render for MapleApp { Screen::PlanGate(gate) => gate.clone().into_any_element(), } }), - ); + "maple-screen", + screen_kind, + )); #[cfg(target_os = "linux")] let root = root.children(self.restart_confirmation.clone()); root diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 427d8f082..8ebbaf35f 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -1764,7 +1764,11 @@ impl Render for SettingsScreen { .flex_1() .min_w_0() .h_full() - .child(self.render_pane(window, cx)) + .child(crate::ui::motion::swap_in( + self.render_pane(window, cx), + "settings-pane-swap", + self.section.id(), + )) .child(crate::ui::scrollbar::scrollbar( "settings-scrollbar", self.pane_scroll.clone(), @@ -2869,8 +2873,10 @@ impl SettingsScreen { }) }; let transport_segment = |id: &'static str, label: gpui::SharedString, active: bool| { + let selected: gpui::Hsla = gpui::rgb(theme::bg_sidebar_row_hover()).into(); div() .id(id) + .relative() .px_3() .py_1() .rounded(theme::RADIUS_SM) @@ -2880,20 +2886,34 @@ impl SettingsScreen { } else { theme::text_secondary() })) - .when(active, |el| el.bg(gpui::rgb(theme::bg_sidebar_row_hover()))) .map(|element| { - let rest: gpui::Hsla = if active { - gpui::rgb(theme::bg_sidebar_row_hover()).into() - } else { - gpui::transparent_black() - }; crate::ui::motion::hover_background( element, - rest, - gpui::rgb(theme::bg_sidebar_row_hover()), + gpui::transparent_black(), + selected, ) }) .hover(|style| style.cursor_pointer()) + // The selected fill, over the hover plate, eases between + // segments. + .child(crate::ui::motion::animated( + "selected", + if active { 1. } else { 0. }, + crate::ui::motion::TOGGLE, + crate::ui::motion::ease_out_cubic, + move |active| { + div() + .absolute() + .inset_0() + .rounded(theme::RADIUS_SM) + .bg(crate::ui::motion::mix( + gpui::transparent_black(), + selected, + active, + )) + .into_any_element() + }, + )) .child(label) }; div() @@ -3283,34 +3303,58 @@ fn is_cua_driver(id: &str) -> bool { id == "cua-driver" } -/// Small on/off pill used in list rows. +/// Small on/off pill used in list rows. Its fill and label color ease +/// between on and off. fn pill_button( id: String, label: gpui::SharedString, on: bool, handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, ) -> gpui::Stateful
{ + use crate::ui::motion; + let target = if on { 1. } else { 0. }; + let off_fill: gpui::Hsla = gpui::rgb(theme::bg_sidebar_card()).into(); + let on_fill: gpui::Hsla = gpui::rgb(theme::accent()).into(); + let off_text: gpui::Hsla = gpui::rgb(theme::text_secondary()).into(); + let on_text: gpui::Hsla = gpui::rgb(theme::on_accent()).into(); div() .id(gpui::SharedString::from(id)) + .relative() .px_2p5() .py_1() .rounded_full() .text_xs() .font_weight(gpui::FontWeight::MEDIUM) - .bg(gpui::rgb(if on { - theme::accent() - } else { - theme::bg_sidebar_card() - })) - .text_color(gpui::rgb(if on { - theme::on_accent() - } else { - theme::text_secondary() - })) .hover(|style| style.opacity(0.85).cursor_pointer()) .active(|style| style.opacity(0.7)) .on_click(handler) - .child(label) + .child(motion::animated( + "pill-fill", + target, + motion::TOGGLE, + motion::ease_out_cubic, + move |on| { + div() + .absolute() + .inset_0() + .rounded_full() + .bg(motion::mix(off_fill, on_fill, on)) + .into_any_element() + }, + )) + .child(motion::animated( + "pill-label", + target, + motion::TOGGLE, + motion::ease_out_cubic, + move |on| { + div() + .relative() + .text_color(motion::mix(off_text, on_text, on)) + .child(label.clone()) + .into_any_element() + }, + )) } /// Whether a key press presses the focused control, as a click does. From 8cb70fa2ca4630b1959578bb22f3cad96d930cd5 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:45:53 -0800 Subject: [PATCH 10/29] Agent: sidebar status and folding animate Task status indicators cross-fade between states, never on first draw; section chevrons rotate, project folder icons cross-fade, and the Settings menu chevron turns as the menu opens. --- apps/maple-agent/app/src/ui/chat/mod.rs | 22 ++- apps/maple-agent/app/src/ui/chat/sidebar.rs | 161 ++++++++++++++++---- 2 files changed, 150 insertions(+), 33 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 8eb643313..319ba3e72 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -7427,13 +7427,21 @@ impl ChatScreen { ) }) .child(div().flex_1()) - .child( - icon("chevron-down", px(12.), theme::text_muted()).when(!open, |icon| { - icon.with_transformation(gpui::Transformation::rotate(gpui::radians( - std::f32::consts::PI, - ))) - }), - ) + .child(motion::animated( + "settings-menu-chevron", + if open { 1. } else { 0. }, + motion::TOGGLE, + motion::ease_out_cubic, + |open| { + // Points up at the menu above while closed, and + // turns down once it is open. + icon("chevron-down", px(12.), theme::text_muted()) + .with_transformation(gpui::Transformation::rotate(gpui::radians( + std::f32::consts::PI * (1. - open), + ))) + .into_any_element() + }, + )) }); let button = self .popup diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 464a3b5f9..9745032d0 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3612,12 +3612,9 @@ impl Sidebar { .size(px(14.)) .flex() .items_center() - .child(icon( - if expanded { - "chevron-down" - } else { - "chevron-right" - }, + .child(motion::chevron( + "chevron", + expanded, px(14.), theme::text_secondary(), )); @@ -3997,8 +3994,10 @@ impl Sidebar { }), ) }) - .child(icon( - if expanded { "folder-open" } else { "folder" }, + .child(motion::icon_swap( + "folder-icon", + expanded, + ["folder", "folder-open"], FOLDER_ICON_SIZE, theme::text_secondary(), )) @@ -4590,29 +4589,57 @@ const RUNNING_BREATH: Duration = Duration::from_millis(1600); /// any number of running rows cost one repaint per interval; `spinner_id` /// is prebuilt so render allocates nothing. Every state sits centered in /// one box the size of the largest, so the center stays put as states -/// change. +/// change, and one state cross-fades into the next over +/// [`motion::TOGGLE`]. The box is keyed by `spinner_id`, which names the +/// task, so an indicator that first appears (a list that loads, a row that +/// scrolls in) shows its state at once. fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> AnyElement { - let dot = |color: u32| { - div() - .size(px(8.)) - .rounded_full() - .bg(gpui::rgb(color)) - .into_any_element() + let layer = |name: &'static str, state: SessionActivity| { + let spinner_id = spinner_id.clone(); + motion::animated( + name, + if activity == state { 1. } else { 0. }, + motion::TOGGLE, + motion::ease_out_cubic, + move |shown| { + if shown <= 0. { + return div().into_any_element(); + } + let dot = |color: u32| { + div() + .size(px(8.)) + .rounded_full() + .bg(gpui::rgb(color)) + .into_any_element() + }; + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .opacity(shown) + .child(match state { + SessionActivity::NeedsYou => dot(theme::accent()), + SessionActivity::Running => running_ring(spinner_id.clone()), + SessionActivity::Failed => { + icon("circle-alert", px(13.), theme::status_error()).into_any_element() + } + SessionActivity::CompletedUnread => dot(theme::status_success()), + }) + .into_any_element() + }, + ) }; div() + .id(spinner_id.clone()) + .relative() .size(px(13.)) .flex_none() - .flex() - .items_center() - .justify_center() - .child(match activity { - SessionActivity::NeedsYou => dot(theme::accent()), - SessionActivity::Running => running_ring(spinner_id.clone()), - SessionActivity::Failed => { - icon("circle-alert", px(13.), theme::status_error()).into_any_element() - } - SessionActivity::CompletedUnread => dot(theme::status_success()), - }) + .child(layer("needs-you", SessionActivity::NeedsYou)) + .child(layer("running", SessionActivity::Running)) + .child(layer("failed", SessionActivity::Failed)) + .child(layer("completed", SessionActivity::CompletedUnread)) .into_any_element() } @@ -4927,6 +4954,88 @@ mod tests { assert_eq!(cx.debug_bounds("view-options"), Some(view)); } + /// A task's status cross-fades inside the cached sidebar: the change + /// asks for frames until it settles, then the sidebar goes idle. A + /// status already there when the list first draws does not animate. + #[gpui::test] + fn task_status_cross_fades_in_the_cached_sidebar_and_settles(cx: &mut gpui::TestAppContext) { + use super::*; + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + let start = std::time::Instant::now(); + motion::set_test_now(Some(start)); + cx.update(|cx| cx.set_reduce_motion(false)); + 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()); + let activity = |waiting: bool| { + let ids = HashSet::from(["s1".to_string()]); + if waiting { + TaskActivity { + waiting: ids, + ..TaskActivity::default() + } + } else { + TaskActivity { + unread: ids, + ..TaskActivity::default() + } + } + }; + sidebar.update(cx, |sidebar, cx| { + sidebar.reset_persisted_for_test(); + sidebar.view.group = SidebarGroup::List; + sidebar.set_sessions( + (1..=3) + .map(|ix| AgentSessionSummary { + id: format!("s{ix}"), + title: format!("Task {ix}"), + project_root: "/tmp/proj".into(), + created_ms: 0, + updated_ms: 100 - ix, + message_count: 1, + model: None, + web_enabled: true, + permission_mode: Default::default(), + state: AgentTaskState::Active, + acp: false, + }) + .collect(), + cx, + ); + sidebar.set_activity(activity(true), cx); + }); + struct Host(Entity); + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child( + self.0 + .clone() + .cached(gpui::StyleRefinement::default().w(px(300.)).h(px(400.))), + ) + } + } + let (_host, cx) = cx.add_window_view(|_, _| Host(sidebar.clone())); + cx.simulate_resize(gpui::size(px(600.), px(400.))); + cx.run_until_parked(); + let frames = |cx: &mut gpui::VisualTestContext| { + cx.update(|window, cx| window.simulate_next_frame(cx)) + }; + assert!(cx.debug_bounds("session-s1").is_some()); + assert_eq!(frames(cx), 0, "a status at first draw does not animate"); + + sidebar.update(cx, |sidebar, cx| sidebar.set_activity(activity(false), cx)); + cx.run_until_parked(); + assert!(frames(cx) > 0, "the new status fades in"); + cx.run_until_parked(); + motion::set_test_now(Some(start + motion::TOGGLE)); + frames(cx); + cx.run_until_parked(); + assert_eq!(frames(cx), 0, "settled, the cached sidebar goes idle"); + motion::set_test_now(None); + } + #[gpui::test] fn collapsed_and_empty_projects_keep_the_expanded_task_gap(cx: &mut gpui::TestAppContext) { use super::*; From 1b1f79cb322e808d33458fa01e86cfbab9daba48 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:46:36 -0800 Subject: [PATCH 11/29] Agent: live transcript rows rise in once as they arrive Rows that arrive live in the open task (the sent message, the reply, thinking, tool cards, errors, the stop notice) fade in with a 6 px rise over 180 ms, timed from view state so a row scrolled away and back never replays it. History, task switches, paging and reconnect rebuilds stay still, as do streaming appends. The send preview rises in and hands off to the real row without a second entrance; the waiting dots fade in. Disclosure chevrons rotate and opened details fade in; a tool call that finishes on screen cross-fades its status. Another task's transcript fades in when it lands, the open spinner waits 300 ms, and notices and the cards above the composer rise in. --- apps/maple-agent/app/src/ui/chat/composer.rs | 3 +- apps/maple-agent/app/src/ui/chat/mod.rs | 89 ++- apps/maple-agent/app/src/ui/chat/sending.rs | 200 ++++--- apps/maple-agent/app/src/ui/chat/tests.rs | 2 + .../maple-agent/app/src/ui/chat/transcript.rs | 563 ++++++++++++++++-- 5 files changed, 703 insertions(+), 154 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 5ecdd33d2..aab8047a8 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -227,8 +227,9 @@ impl ChatScreen { .w(px(14.)) .h(px(14.)) .debug_selector(|| "navigation-loading-slot".to_string()) + // A task that opens quickly never flashes the spinner. .when( - self.loading_session.is_some() || self.root_selecting || self.booting, + self.loading_spinner_due() || self.root_selecting || self.booting, |slot| slot.child(spinner("navigation-loading", px(14.), theme::text_muted())), ), ) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 319ba3e72..912c83b70 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -218,6 +218,9 @@ struct TranscriptCtx<'a> { /// One-based position of the row and the row count, for /// assistive technology ("message 4 of 17"). position: (usize, usize), + /// How far the row's just-opened details are through fading in; 1 at + /// rest. + reveal: f32, } /// Text-to-speech progress for one message. @@ -725,6 +728,12 @@ pub struct ChatScreen { /// True from send until the first agent item of the run arrives; shows /// the waiting indicator under the transcript. awaiting_first_token: bool, + /// Live row entrances and reveals in the transcript, kept here so a + /// row scrolled away and back never replays them. + row_motion: transcript::RowMotion, + /// When the task in `loading_session` started opening, for the header + /// spinner's delay. + loading_since: Option, /// Item ids whose tool card or thinking row was clicked; membership /// inverts the `tool_details` default for that item. toggled_tools: HashSet, @@ -832,6 +841,9 @@ struct HostRecovery { /// How often a stale snapshot is fetched again before it is applied. const LOAD_RETRIES: u8 = 2; +/// How long a task may take to open before the header shows a spinner; +/// a faster open never flashes one. +const LOADING_SPINNER_DELAY: std::time::Duration = std::time::Duration::from_millis(300); /// Finished run ids remembered for a late send acknowledgement. const FINISHED_RUNS_KEPT: usize = 64; @@ -1534,6 +1546,8 @@ impl ChatScreen { selection: None, transcript_focus: None, awaiting_first_token: false, + row_motion: transcript::RowMotion::default(), + loading_since: None, toggled_tools: HashSet::new(), question_selected: HashMap::new(), question_step: 0, @@ -3016,6 +3030,8 @@ impl ChatScreen { if matches!(mode, LoadMode::Select) && !self.is_selected(session_id) { if self.loading_session.as_deref() != Some(session_id) { self.loading_timeline.clear(); + self.loading_since = Some(motion::now()); + self.schedule_loading_spinner(cx); } self.loading_session = Some(session_id.to_string()); self.loading_generation = self.selection_generation; @@ -3113,8 +3129,15 @@ impl ChatScreen { let live_has_more = detail.live_has_more; match mode { LoadMode::Select => { + // Another task's transcript fades in when it lands; + // the task restored at launch (`apply_bootstrap`) + // does not. + let switching = !this.is_selected(&detail.session.id); this.upsert_session(detail.session.clone(), cx); this.set_active_session(detail.session, detail.timeline, summaries, cx); + if switching { + this.row_motion.switch_task(motion::now()); + } this.set_queue(detail.queue.items); } LoadMode::Reload => { @@ -3148,6 +3171,31 @@ impl ChatScreen { } } + /// Repaint once an open has taken `LOADING_SPINNER_DELAY`, so a slow + /// open shows the header spinner. + fn schedule_loading_spinner(&self, cx: &mut Context) { + cx.spawn(async move |entity, cx| { + cx.background_executor().timer(LOADING_SPINNER_DELAY).await; + entity + .update(cx, |this, cx| { + if this.loading_session.is_some() { + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + /// Whether the header shows the spinner of a task being opened: only + /// once the open has taken `LOADING_SPINNER_DELAY`. + pub(super) fn loading_spinner_due(&self) -> bool { + self.loading_session.is_some() + && self.loading_since.is_none_or(|since| { + motion::now().saturating_duration_since(since) >= LOADING_SPINNER_DELAY + }) + } + /// Install a new timeline and reset every per-item structure that is /// keyed by its contents. fn replace_timeline(&mut self, timeline: Vec) { @@ -3158,6 +3206,7 @@ impl ChatScreen { .application_vim_enabled .then(|| self.navigable_timeline_ids()); self.local_error_text.clear(); + self.row_motion.clear_rows(); self.timeline = timeline.into_iter().map(paging::normalize).collect(); self.pages.weights.clear(); self.bound_transcript_window(true); @@ -4255,16 +4304,27 @@ impl ChatScreen { !disconnected(notice) })?; self.schedule_notice_dismiss(notice.clone(), cx); - Some(div().child(motion::fade_in( + let text = notice.resolve(); + // Keyed by what it says, so a different notice that replaces this + // one enters again. + let identity = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() + }; + Some(div().child(motion::slide_in( widgets::notice( "notice-close", - notice.resolve(), + text, cx.listener(|this, _event, _window, cx| { this.notice = None; cx.notify(); }), ), - "notice-reveal", + ("notice-reveal", identity), + motion::ENTER, + gpui::point(px(0.), motion::RISE), ))) } @@ -4661,6 +4721,8 @@ impl ChatScreen { } else { self.toggled_tools.insert(item_id.to_string()); } + let open = self.tool_details != self.toggled_tools.contains(item_id); + self.row_motion.disclose(item_id, open, motion::now()); // Resolve the row now: an index baked into the card at render // time goes stale once history reloads or items are inserted. if let Some(&(index, _)) = self.timeline_index.get(item_id) { @@ -5931,6 +5993,7 @@ impl ChatScreen { self.timeline[index].id = item.id.clone(); self.timeline_index.insert(item.id.clone(), (index, 0)); self.rekey_summary(&old, &item.id); + self.row_motion.rekey(&old, &item.id); } if let Some((index, _)) = self.timeline_index.get(&item.id) && let (Some(old), Some(new)) = (&self.timeline[*index].persisted, &item.persisted) @@ -6020,6 +6083,7 @@ impl ChatScreen { None => { let index = self.timeline.len(); self.timeline_index.insert(item.id.clone(), (index, 0)); + self.row_motion.arrive(&item.id, motion::now()); self.timeline.push(item); self.list_state.splice(index..index, 1); if index == 0 { @@ -6327,7 +6391,11 @@ impl ChatScreen { } } this.refresh_sessions(cx); + // Pushes that crossed the reconnect rebuild the + // transcript; they do not enter like live rows. + this.row_motion.set_quiet(true); this.handle_service_events(events, cx); + this.row_motion.set_quiet(false); this.host_recovery.unavailable = false; this.runtime_error_reason = None; this.runtime_error = None; @@ -7091,6 +7159,7 @@ impl Render for ChatScreen { .map(|status| self.render_trust_prompt(&status, cx)); let empty = self.shows_empty_state(); let sidebar_expansion = self.sidebar_expansion(window, cx); + let rise = gpui::point(px(0.), motion::RISE); let main = if empty { self.render_empty_state(window, cx) } else { @@ -7151,9 +7220,17 @@ impl Render for ChatScreen { // own wheel; the space beside them scrolls the // transcript. .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) - .children(self.render_btw_card(cx)) - .children(self.render_subagents_card(cx)) - .children(self.render_plan_card(cx)) + // Cards above the composer rise in when they appear + // and leave at once. + .children(self.render_btw_card(cx).map(|card| { + motion::slide_in(card, "btw-card-enter", motion::ENTER, rise) + })) + .children(self.render_subagents_card(cx).map(|card| { + motion::slide_in(card, "subagents-card-enter", motion::ENTER, rise) + })) + .children(self.render_plan_card(cx).map(|card| { + motion::slide_in(card, "plan-card-enter", motion::ENTER, rise) + })) .child(self.render_composer(window, cx)) .children(self.render_slash_palette(cx)) .children(self.render_persistence_failure(cx)), diff --git a/apps/maple-agent/app/src/ui/chat/sending.rs b/apps/maple-agent/app/src/ui/chat/sending.rs index eebd3bcb5..90657efa1 100644 --- a/apps/maple-agent/app/src/ui/chat/sending.rs +++ b/apps/maple-agent/app/src/ui/chat/sending.rs @@ -184,6 +184,7 @@ impl ChatScreen { let Some(run) = run.filter(|_| is_user_message(item)) else { return; }; + let mut handed_off = false; for preview in self .sending_previews .values_mut() @@ -201,10 +202,17 @@ impl ChatScreen { { continue; } + let showing = preview.body_visible(); if preview.projected_runs.len() < 8 || preview.acknowledged_run.as_deref() == Some(run) { preview.projected_runs.insert(run.to_owned()); } + handed_off |= showing && !preview.body_visible(); + } + // The row takes the place of the message already on screen; it + // must not enter a second time. + if handed_off { + self.row_motion.hand_off(item.id.clone()); } self.sending_previews .retain(|_, preview| !preview.settled()); @@ -289,104 +297,108 @@ impl ChatScreen { .retain(|_, preview| !preview.settled()); } - pub(super) fn render_send_preview(&self) -> Option> { + /// The message being sent, under the transcript. It rises in when the + /// send starts; the real row that replaces it does not enter again. + pub(super) fn render_send_preview(&self) -> Option> { let preview = self.current_send_preview()?; - Some( - div() - .id("pending-send") - .debug_selector(|| "pending-send".to_string()) - .role(gpui::Role::Status) - .aria_label(if self.send_pending() { - tr("chat-sending-message") - } else { - tr("chat-waiting-response") - }) - .flex_none() - .w_full() - .max_w(CONTENT_WIDTH) - .mx_auto() - .px_6() - .pb_2() - .flex() - .flex_col() - .items_end() - .gap_1() - .when(preview.body_visible(), |row| { - row.child( - crate::ui::typography::chat_reading(div()) - .id("pending-send-body") - .debug_selector(|| "pending-send-body".to_string()) - .max_w(gpui::relative(0.75)) - .max_h(px(180.)) - .overflow_y_scroll() - // A long message scrolls inside its bubble; the - // pane must not scroll the transcript as well. - .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) - .px_4() - .py_2() - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::bg_user_bubble())) - .border_1() - .border_color(gpui::rgb(theme::user_bubble_border())) - .text_color(gpui::rgb(theme::text_primary())) - .when(!preview.attachments.is_empty(), |body| { - body.child(div().flex().flex_wrap().gap_2().mb_1().children( - preview.attachments.iter().map(|attachment| { - match &attachment.image { - Some(image) => div().child( - gpui::img(gpui::ImageSource::Image(image.clone())) - .size_16() - .rounded(theme::RADIUS_SM), + let row = div() + .id("pending-send") + .debug_selector(|| "pending-send".to_string()) + .role(gpui::Role::Status) + .aria_label(if self.send_pending() { + tr("chat-sending-message") + } else { + tr("chat-waiting-response") + }) + .flex_none() + .w_full() + .max_w(CONTENT_WIDTH) + .mx_auto() + .px_6() + .pb_2() + .flex() + .flex_col() + .items_end() + .gap_1() + .when(preview.body_visible(), |row| { + row.child( + crate::ui::typography::chat_reading(div()) + .id("pending-send-body") + .debug_selector(|| "pending-send-body".to_string()) + .max_w(gpui::relative(0.75)) + .max_h(px(180.)) + .overflow_y_scroll() + // A long message scrolls inside its bubble; the + // pane must not scroll the transcript as well. + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .px_4() + .py_2() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_user_bubble())) + .border_1() + .border_color(gpui::rgb(theme::user_bubble_border())) + .text_color(gpui::rgb(theme::text_primary())) + .when(!preview.attachments.is_empty(), |body| { + body.child(div().flex().flex_wrap().gap_2().mb_1().children( + preview.attachments.iter().map(|attachment| { + match &attachment.image { + Some(image) => div().child( + gpui::img(gpui::ImageSource::Image(image.clone())) + .size_16() + .rounded(theme::RADIUS_SM), + ), + None => div() + .flex() + .flex_col() + .text_xs() + .gap_1() + .child(attachment.name.clone()) + .child( + div() + .text_color(gpui::rgb(theme::text_muted())) + .child(attachments::cached_attachment_detail( + &attachment.mime, + attachment.size, + &self.messages, + )), ), - None => div() - .flex() - .flex_col() - .text_xs() - .gap_1() - .child(attachment.name.clone()) - .child( - div() - .text_color(gpui::rgb(theme::text_muted())) - .child( - attachments::cached_attachment_detail( - &attachment.mime, - attachment.size, - &self.messages, - ), - ), - ), - } - }), - )) - }) - .child(preview.text.clone()), - ) - }) - .child( - div() - .flex() - .items_center() - .gap_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(spinner( - "pending-send-spinner", - px(12.), - theme::text_muted(), - )) - .child( - self.selected_upload() - .map(|(_, upload)| upload.label.clone()) - .unwrap_or_else(|| { - if self.send_pending() { - UiText::key("chat-sending") - } else { - UiText::key("chat-waiting-response-progress") } }), - ), - ), - ) + )) + }) + .child(preview.text.clone()), + ) + }) + .child( + div() + .flex() + .items_center() + .gap_1() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(spinner( + "pending-send-spinner", + px(12.), + theme::text_muted(), + )) + .child( + self.selected_upload() + .map(|(_, upload)| upload.label.clone()) + .unwrap_or_else(|| { + if self.send_pending() { + UiText::key("chat-sending") + } else { + UiText::key("chat-waiting-response-progress") + } + }), + ), + ); + Some(motion::slide_in( + row, + "pending-send-enter", + motion::ENTER, + gpui::point(px(0.), motion::RISE), + )) } } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 527e628f1..6fdb0cf0c 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -13370,6 +13370,8 @@ mod state_tests { #[gpui::test] fn pending_first_send_renders_at_the_conversation_position(cx: &mut TestAppContext) { + // Asserts the preview's settled place, not its entrance. + cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { this.selected_session = None; this.draft = true; diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 6e52c9f28..f555cd7e0 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Instant; use gpui::{Div, Entity, IntoElement, SharedString, Window, div, prelude::*, px}; use maple_agent::agent::{ @@ -41,6 +42,14 @@ impl ChatScreen { self.list_state .reset_with_uniform_height(count, super::TRANSCRIPT_ROW_ESTIMATE); } + // Entrances read one clock for the whole frame and ask for the next + // frame only while one is still running. + let now = motion::now(); + let reduced = cx.reduce_motion(); + if self.row_motion.settle(now, reduced) { + window.request_animation_frame(); + } + let switch_reveal = self.row_motion.switch_progress(now, reduced); let show_jump = count > 0 && !self.list_state.is_following_tail(); let tool_details = self.tool_details; let entity = cx.entity().downgrade(); @@ -58,6 +67,12 @@ impl ChatScreen { return div().into_any_element(); }; let chat = chat_entity.read(cx); + // A row that arrived live rises in, timed from view state so + // scrolling it away and back never replays the entrance. + let arrival = chat + .timeline + .get(ix) + .map_or(1., |item| chat.row_motion.arrival(&item.id, now, reduced)); let row = match chat.timeline.get(ix) { Some(item) => { let render_ctx = RenderCtx { @@ -80,6 +95,7 @@ impl ChatScreen { speech: chat.speech.as_ref(), speech_available: chat.audio_caps.speech, position: (ix + 1, chat.timeline.len()), + reveal: chat.row_motion.disclosure(&item.id, now, reduced), }; let expanded = tool_details != chat.toggled_tools.contains(&item.id); let revision = chat @@ -140,7 +156,7 @@ impl ChatScreen { // measured for overdraw are not, and a focused row kept alive // off screen lands outside the mask. let painted_rows = painted_rows.clone(); - div() + let row = div() .child(row) .on_children_prepainted(move |bounds, window, _cx| { let view = window.content_mask().bounds; @@ -153,8 +169,14 @@ impl ChatScreen { None => (ix, ix + 1), })); } - }) - .into_any_element() + }); + // Paint offset and opacity only: the row measures the same + // throughout its entrance. + if arrival < 1. { + motion::arrive(row, arrival).into_any_element() + } else { + row.into_any_element() + } }) .size_full(); div() @@ -216,6 +238,10 @@ impl ChatScreen { .mx_auto() .px_6() .pb_4() + // Another task's transcript fades in when it lands. + .when(switch_reveal < 1., |rows| { + rows.opacity(motion::ease_out_quint(switch_reveal)) + }) .child( // The list's own wheel scrolls it; the pane's must // not scroll it a second time. @@ -346,6 +372,113 @@ impl ChatScreen { } } +/// Entrances in the transcript, timed from view state. A `gpui::list` +/// drops a row's element state when it scrolls away, so an entrance kept +/// there would replay; these start times outlive the elements, and a +/// finished one is pruned, so a row animates once. +#[derive(Default)] +pub(super) struct RowMotion { + /// Rows that arrived live in the task on screen, by item id. + arrived: HashMap, + /// Rows whose details the user just opened, by item id. + disclosed: HashMap, + /// When another task's transcript landed. + switched: Option, + /// The user row that takes over from the send preview already on + /// screen; it does not enter a second time. + handed_off: Option, + /// Rows apply without an entrance (events replayed on reconnect). + quiet: bool, +} + +impl RowMotion { + /// A row was added live; history, paging and snapshots never call this. + pub(super) fn arrive(&mut self, id: &str, now: Instant) { + let handed_off = self.handed_off.take(); + if self.quiet || handed_off.as_deref() == Some(id) { + return; + } + self.arrived.insert(id.to_owned(), now); + } + + /// The next row with `id` replaces the send preview on screen. + pub(super) fn hand_off(&mut self, id: String) { + self.handed_off = Some(id); + } + + /// Apply rows without an entrance until called again with `false`. + pub(super) fn set_quiet(&mut self, quiet: bool) { + self.quiet = quiet; + } + + /// A live row took its persisted id; its entrance carries on. + pub(super) fn rekey(&mut self, old: &str, new: &str) { + for started in [&mut self.arrived, &mut self.disclosed] { + if let Some(at) = started.remove(old) { + started.insert(new.to_owned(), at); + } + } + } + + /// The user opened or closed a row's details. Closing is immediate. + pub(super) fn disclose(&mut self, id: &str, open: bool, now: Instant) { + if open { + self.disclosed.insert(id.to_owned(), now); + } else { + self.disclosed.remove(id); + } + } + + /// Another task's transcript landed. + pub(super) fn switch_task(&mut self, now: Instant) { + self.switched = Some(now); + } + + /// A new transcript replaced every row; none of them is arriving. + pub(super) fn clear_rows(&mut self) { + self.arrived.clear(); + self.disclosed.clear(); + self.handed_off = None; + } + + /// Progress of the row's entrance; 1 for a row that did not arrive live + /// or has settled. + pub(super) fn arrival(&self, id: &str, now: Instant, reduced: bool) -> f32 { + self.arrived + .get(id) + .map_or(1., |at| motion::progress(*at, motion::ENTER, now, reduced)) + } + + /// Progress of the fade of details the user just opened. + pub(super) fn disclosure(&self, id: &str, now: Instant, reduced: bool) -> f32 { + self.disclosed + .get(id) + .map_or(1., |at| motion::progress(*at, motion::REVEAL, now, reduced)) + } + + /// Progress of the fade of a task's transcript after a switch. + pub(super) fn switch_progress(&self, now: Instant, reduced: bool) -> f32 { + self.switched + .map_or(1., |at| motion::progress(at, motion::REVEAL, now, reduced)) + } + + /// Drop what has finished; true while something still moves, so the + /// caller asks for another frame. + pub(super) fn settle(&mut self, now: Instant, reduced: bool) -> bool { + if reduced { + self.arrived.clear(); + self.disclosed.clear(); + self.switched = None; + return false; + } + let running = |at: &Instant, duration| now.saturating_duration_since(*at) < duration; + self.arrived.retain(|_, at| running(at, motion::ENTER)); + self.disclosed.retain(|_, at| running(at, motion::REVEAL)); + self.switched = self.switched.filter(|at| running(at, motion::REVEAL)); + !self.arrived.is_empty() || !self.disclosed.is_empty() || self.switched.is_some() + } +} + pub(super) fn render_timeline_item( item: &AgentTimelineItem, revision: u64, @@ -755,12 +888,9 @@ fn render_thinking( }) .ok(); }) - .child(icon( - if expanded { - "chevron-down" - } else { - "chevron-right" - }, + .child(motion::chevron( + SharedString::from(format!("thinking-chevron-{}", item.id)), + expanded, px(14.), theme::text_muted(), )) @@ -785,7 +915,7 @@ fn render_thinking( if !expanded { return card; } - card.child( + card.child(revealed( typography::chat_reading(div()) .text_color(gpui::rgb(theme::text_secondary())) .child(markdown::render_with( @@ -794,7 +924,18 @@ fn render_thinking( .get(&item.id, MarkdownKind::Body, revision, &text), transcript.render, )), - ) + transcript.reveal, + )) +} + +/// Details the user just opened fade in; `reveal` is how far along they +/// are. Their height is there at once, so the list measures it once. +fn revealed(details: E, reveal: f32) -> E { + if reveal < 1. { + details.opacity(motion::ease_out_quint(reveal)) + } else { + details + } } fn tool_status_style(status: Option<&str>) -> (SharedString, u32) { @@ -806,6 +947,58 @@ fn tool_status_style(status: Option<&str>) -> (SharedString, u32) { } } +/// A tool card's spinner and status label. A call that finishes on screen +/// cross-fades from its running state over [`motion::TOGGLE`]; a card that +/// mounts finished (history, a row scrolled back) shows its state at once. +fn tool_status(item_id: &str, running: bool, label: SharedString, color: u32) -> motion::Animated { + let spinner_id = SharedString::from(format!("tool-spinner-{item_id}")); + motion::animated( + SharedString::from(format!("tool-status-{item_id}")), + if running { 0. } else { 1. }, + motion::TOGGLE, + motion::ease_out_cubic, + move |done| { + let state = |spinning: bool, label: SharedString, color: u32| { + div() + .flex() + .items_center() + .gap_2() + .when(spinning, |state| { + state.child(spinner_with_id( + spinner_id.clone(), + px(12.), + theme::accent(), + )) + }) + .child(div().text_xs().text_color(gpui::rgb(color)).child(label)) + }; + if running { + return state(true, label.clone(), color) + .opacity(1. - done) + .into_any_element(); + } + // The running state fades out over the outcome, aligned to the + // same right edge, then leaves. + div() + .relative() + .child(state(false, label.clone(), color).opacity(done)) + .when(done < 1., |slot| { + let (running_label, running_color) = tool_status_style(None); + slot.child( + state(true, running_label, running_color) + .absolute() + .top_0() + .bottom_0() + .right_0() + .whitespace_nowrap() + .opacity(1. - done), + ) + }) + .into_any_element() + }, + ) +} + /// True when the tool call input has the given top-level key. pub(super) fn has_tool_input(item: &AgentTimelineItem, key: &str) -> bool { item.input @@ -969,24 +1162,22 @@ fn render_finished_plan( .text_color(gpui::rgb(theme::text_muted())) .child(count), ) - .child(icon( - if details { - "chevron-down" - } else { - "chevron-right" - }, + .child(motion::chevron( + SharedString::from(format!("plan-chevron-{}", item.id)), + details, px(14.), theme::text_muted(), )), ); if details { - card = card.child( + card = card.child(revealed( div() .flex() .flex_col() .gap_1() .children(plan.iter().map(render_plan_row)), - ); + transcript.reveal, + )); } Some(div().child(card)) } @@ -1223,7 +1414,7 @@ fn render_tool_with_diff( ); } // The diff is content: swallow clicks so selecting it does not toggle. - card.child( + card.child(revealed( div() .id(gpui::SharedString::from(format!("tool-diff-{}", item.id))) .on_click( @@ -1232,7 +1423,8 @@ fn render_tool_with_diff( }, ) .child(diff), - ) + transcript.reveal, + )) } /// Title shown before the model summary arrives: just the tool label from @@ -1334,25 +1526,10 @@ fn render_tool( .debug_selector(|| "tool-title".to_string()) .child(title), ) - .when(running, |header| { - header.child(spinner_with_id( - SharedString::from(format!("tool-spinner-{}", item.id)), - px(12.), - theme::accent(), - )) - }) - .child( - div() - .text_xs() - .text_color(gpui::rgb(status_color)) - .child(label), - ) - .child(icon( - if details { - "chevron-down" - } else { - "chevron-right" - }, + .child(tool_status(&item.id, running, label, status_color)) + .child(motion::chevron( + SharedString::from(format!("tool-chevron-{}", item.id)), + details, px(14.), theme::text_muted(), )), @@ -1364,8 +1541,9 @@ fn render_tool( } let derived = transcript.derived.get(item, revision); if let Some(activity) = &derived.external_agent { - return div().child(card.child(render_external_agent_activity( - item, revision, activity, transcript, + return div().child(card.child(revealed( + render_external_agent_activity(item, revision, activity, transcript), + transcript.reveal, ))); } // A click anywhere on the card, payload included, toggles it. @@ -1402,7 +1580,7 @@ fn render_tool( )), ); } - div().child(card.child(payload)) + div().child(card.child(revealed(payload, transcript.reveal))) } /// Keep a card's output out of the shared selection, which would treat its @@ -1832,12 +2010,9 @@ pub(super) fn render_question_card( .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_question_collapsed(cx); })) - .child(icon( - if collapsed { - "chevron-right" - } else { - "chevron-down" - }, + .child(motion::chevron( + "question-card-chevron", + !collapsed, px(14.), theme::text_secondary(), )) @@ -2082,10 +2257,11 @@ pub(super) fn render_question_card( /// Pulsing dots shown between send and the first streamed content. They /// breathe on the shared low-rate clock, so waiting costs the same as a -/// spinner rather than a full-rate animation. -pub(super) fn render_waiting_indicator() -> gpui::Stateful
{ +/// spinner rather than a full-rate animation. They fade in when they +/// appear and leave at once when the reply arrives. +pub(super) fn render_waiting_indicator() -> impl IntoElement { const CYCLE: std::time::Duration = std::time::Duration::from_millis(1400); - div() + let indicator = div() .id("waiting-indicator") .role(gpui::Role::Status) .aria_label(tr("chat-thinking-label")) @@ -2113,5 +2289,286 @@ pub(super) fn render_waiting_indicator() -> gpui::Stateful
{ .text_sm() .text_color(gpui::rgb(theme::text_muted())) .child(tr("chat-thinking-label")), - ) + ); + motion::fade_in(indicator, "waiting-indicator-reveal") +} + +#[cfg(test)] +mod motion_tests { + use std::time::{Duration, Instant}; + + use gpui::{Context, TestAppContext}; + + use crate::ui::chat::*; + use crate::ui::motion; + + fn item(id: &str, item_type: &str, text: Option<&str>) -> AgentTimelineItem { + AgentTimelineItem { + label: None, + persisted: None, + live: None, + truncated: None, + revision: 0, + id: id.to_string(), + item_type: item_type.to_string(), + role: None, + title: None, + text: text.map(str::to_string), + status: None, + input: None, + output: None, + created_ms: 0, + merge: "replace".to_string(), + } + } + + fn user_item(id: &str, text: &str) -> AgentTimelineItem { + AgentTimelineItem { + role: Some("user".to_string()), + ..item(id, "message", Some(text)) + } + } + + fn screen(cx: &mut TestAppContext) -> Entity { + cx.executor().allow_parking(); + cx.new(|cx| { + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + let backend = std::sync::Arc::new( + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), + ); + let mut chat = ChatScreen::new_inner_for_test(backend, "user".to_string(), cx); + chat.selected_session = Some("s1".to_string()); + chat + }) + } + + /// Pins the motion clock for one test and returns it to the real + /// clock when the test ends, pass or fail. + struct Clock(Instant); + + impl Clock { + fn start() -> Self { + let start = Instant::now(); + motion::set_test_now(Some(start)); + Self(start) + } + + fn at(&self, offset: Duration) -> Instant { + motion::set_test_now(Some(self.0 + offset)); + self.0 + offset + } + } + + impl Drop for Clock { + fn drop(&mut self) { + motion::set_test_now(None); + } + } + + /// Whether any transcript entrance still asks for frames at `now`. + fn in_flight( + chat: &Entity, + now: Instant, + cx: &mut gpui::VisualTestContext, + ) -> bool { + chat.update(cx, |chat, _| chat.row_motion.settle(now, false)) + } + + #[gpui::test] + fn a_live_row_rises_in_once_and_keeps_its_height(cx: &mut TestAppContext) { + struct TranscriptHost { + chat: Entity, + } + impl Render for TranscriptHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(1200.)).h(px(800.)).flex().flex_col().child( + self.chat + .update(cx, |chat, cx| chat.render_transcript(window, cx)), + ) + } + } + let clock = Clock::start(); + let chat = screen(cx); + chat.update(cx, |chat, _| { + chat.replace_timeline( + (0..30) + .map(|index| item(&format!("h{index}"), "message", Some("Earlier row"))) + .collect(), + ); + }); + let (_host, cx) = cx.add_window_view(|_, _| TranscriptHost { chat: chat.clone() }); + cx.simulate_resize(gpui::size(px(1200.), px(800.))); + cx.run_until_parked(); + assert!(!in_flight(&chat, clock.0, cx), "history arrives at rest"); + + let redraw = |cx: &mut gpui::VisualTestContext| { + chat.update(cx, |_, cx| cx.notify()); + cx.run_until_parked(); + }; + chat.update(cx, |chat, cx| { + chat.apply_incoming_item("s1", item("live", "message", Some("Hello")), cx); + }); + redraw(cx); + let entering = cx + .debug_bounds("timeline-row-live") + .expect("live row painted"); + + let midway = clock.at(motion::ENTER / 2); + let progress = chat.update(cx, |chat, _| chat.row_motion.arrival("live", midway, false)); + assert!( + progress > 0. && progress < 1., + "mid-entrance, got {progress}" + ); + assert!(in_flight(&chat, midway, cx), "frames continue mid-entrance"); + + let done = clock.at(motion::ENTER); + redraw(cx); + let settled = cx + .debug_bounds("timeline-row-live") + .expect("live row painted"); + assert_eq!( + entering.size, settled.size, + "the entrance never changes layout" + ); + assert_eq!(entering.origin.y - settled.origin.y, motion::RISE); + assert!(!in_flight(&chat, done, cx), "frames stop once it settles"); + + // Scrolled away and back, the row is drawn at rest: no replay. + chat.update(cx, |chat, _| { + chat.list_state.scroll_to(gpui::ListOffset { + item_ix: 0, + offset_in_item: px(0.), + }); + }); + redraw(cx); + chat.update(cx, |chat, _| chat.list_state.scroll_to_end()); + redraw(cx); + assert_eq!(cx.debug_bounds("timeline-row-live"), Some(settled)); + assert!(!in_flight(&chat, done, cx)); + } + + #[gpui::test] + fn history_task_switches_paging_and_reconnects_do_not_animate(cx: &mut TestAppContext) { + let clock = Clock::start(); + let chat = screen(cx); + chat.update(cx, |chat, cx| { + let now = clock.0; + // A history load. + chat.replace_timeline(vec![ + user_item("u1", "hi"), + item("a1", "message", Some("yo")), + ]); + assert_eq!(chat.row_motion.arrival("a1", now, false), 1.); + // Another task's snapshot replaces a row that was still entering. + chat.apply_incoming_item("s1", item("a2", "message", Some("more")), cx); + assert!(chat.row_motion.arrival("a2", now, false) < 1.); + chat.set_active_session( + AgentSessionSummary { + web_enabled: true, + permission_mode: Default::default(), + state: maple_agent::agent::AgentTaskState::Active, + acp: false, + id: "s2".to_string(), + title: "Other".to_string(), + project_root: "/tmp/proj".to_string(), + created_ms: 0, + updated_ms: 0, + message_count: 1, + model: None, + }, + vec![ + item("a2", "message", Some("more")), + item("b1", "message", None), + ], + HashMap::new(), + cx, + ); + assert_eq!(chat.row_motion.arrival("a2", now, false), 1.); + assert_eq!(chat.row_motion.arrival("b1", now, false), 1.); + // A forward history page is open: live rows wait for a reload. + chat.pages.after = true; + chat.apply_incoming_item("s2", item("paged", "message", Some("x")), cx); + assert_eq!(chat.row_motion.arrival("paged", now, false), 1.); + chat.pages.after = false; + // Pushes replayed after a reconnect rebuild the transcript. + chat.row_motion.set_quiet(true); + chat.apply_incoming_item("s2", item("replayed", "message", Some("x")), cx); + chat.row_motion.set_quiet(false); + assert_eq!(chat.row_motion.arrival("replayed", now, false), 1.); + assert!(!chat.row_motion.settle(now, false)); + // Reduced motion: a live row is at rest at once. + chat.apply_incoming_item("s2", item("live", "message", Some("x")), cx); + assert_eq!(chat.row_motion.arrival("live", now, true), 1.); + assert!(!chat.row_motion.settle(now, true)); + }); + } + + #[gpui::test] + fn streaming_into_a_row_does_not_restart_its_entrance(cx: &mut TestAppContext) { + let clock = Clock::start(); + let chat = screen(cx); + let chunk = || AgentTimelineItem { + merge: "append".to_string(), + text: Some(" more".to_string()), + ..item("reply", "message", None) + }; + chat.update(cx, |chat, cx| { + chat.apply_incoming_item("s1", item("reply", "message", Some("Hi")), cx); + let midway = clock.at(motion::ENTER / 2); + let before = chat.row_motion.arrival("reply", midway, false); + chat.apply_incoming_item("s1", chunk(), cx); + assert_eq!(chat.row_motion.arrival("reply", midway, false), before); + let done = clock.at(motion::ENTER); + assert!(!chat.row_motion.settle(done, false)); + chat.apply_incoming_item("s1", chunk(), cx); + assert_eq!(chat.timeline[0].text.as_deref(), Some("Hi more more")); + assert_eq!(chat.row_motion.arrival("reply", done, false), 1.); + assert!(!chat.row_motion.settle(done, false)); + }); + } + + #[gpui::test] + fn the_row_replacing_the_send_preview_does_not_enter_again(cx: &mut TestAppContext) { + let clock = Clock::start(); + let chat = screen(cx); + chat.update(cx, |chat, cx| { + let preview = sending::SendPreview::new(Some("s1".into()), "hello", &[]) + .capture_timeline(&chat.timeline); + chat.sending_previews.insert("send-1".into(), preview); + assert!(chat.has_send_feedback()); + let projected = |id: &str, run: &str| AgentServiceEvent::TimelineItem { + session_id: "s1".into(), + run_id: Some(run.into()), + item: user_item(id, "hello"), + }; + chat.handle_service_event(projected("mine", "run-1"), cx); + assert_eq!(chat.timeline.len(), 1); + assert_eq!(chat.row_motion.arrival("mine", clock.0, false), 1.); + // A message sent from another computer has no preview here. + chat.handle_service_event(projected("theirs", "run-2"), cx); + assert!(chat.row_motion.arrival("theirs", clock.0, false) < 1.); + }); + } + + #[gpui::test] + fn opening_a_task_shows_the_spinner_only_after_a_moment(cx: &mut TestAppContext) { + let clock = Clock::start(); + let chat = screen(cx); + chat.update(cx, |chat, cx| { + chat.select_session("s2", cx); + assert_eq!(chat.loading_session.as_deref(), Some("s2")); + assert!(!chat.loading_spinner_due()); + clock.at(super::super::LOADING_SPINNER_DELAY - Duration::from_millis(1)); + assert!(!chat.loading_spinner_due()); + clock.at(super::super::LOADING_SPINNER_DELAY); + assert!(chat.loading_spinner_due()); + // Clicking the same task again keeps the open's start time. + chat.select_session("s2", cx); + assert!(chat.loading_spinner_due()); + }); + } } From d0a5d69e79b503031a9f884aee3d362876e08c8f Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:46:36 -0800 Subject: [PATCH 12/29] Agent: queued messages rise in above the composer A queued chip fades in with the shared 6 px rise when its message is queued and leaves at once when it is sent, edited away or removed. --- apps/maple-agent/app/src/ui/chat/queue.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/queue.rs b/apps/maple-agent/app/src/ui/chat/queue.rs index 98a2d8576..e9bd5474b 100644 --- a/apps/maple-agent/app/src/ui/chat/queue.rs +++ b/apps/maple-agent/app/src/ui/chat/queue.rs @@ -4,7 +4,7 @@ use gpui::{ Context, Div, InteractiveElement, ParentElement, SharedString, StatefulInteractiveElement, - Styled, div, prelude::FluentBuilder, px, + Styled, div, point, prelude::FluentBuilder, px, }; use maple_agent::agent::AgentQueuedMessage; @@ -12,6 +12,7 @@ use super::{COMPOSER_PLACEHOLDER, ChatScreen, QUEUE_EDIT_PLACEHOLDER, QueueEdit} use crate::backend::QueuedMessageRequest; use crate::ui::i18n::{UiText, tr}; use crate::ui::icons::icon; +use crate::ui::motion; use crate::ui::task::Failure as UiFailure; use crate::ui::theme; @@ -369,7 +370,10 @@ impl ChatScreen { } else { self.queue_previews.get(index).cloned().unwrap_or_default() }; - div() + // A chip rises in when its message is queued and + // leaves at once. + let chip_id = SharedString::from(format!("queue-chip-{}", item.queue_id)); + let chip = div() .flex() .items_center() .gap_2() @@ -447,7 +451,8 @@ impl ChatScreen { }, )), ) - }) + }); + motion::slide_in(chip, chip_id, motion::ENTER, point(px(0.), motion::RISE)) })), ) } From 58183525f9381e3d057907b7d6f9423ff67486eb Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:52:57 -0800 Subject: [PATCH 13/29] Agent: multi-line fields grow and shrink smoothly A multi-line field whose rows change because its text changed (typing, paste, delete, newline, clearing after send, text set from code) eases its height to the new row count over resize_duration with ease-out cubic, retargeting from the height on screen. The first layout, a new wrap width (window resize, the sidebar sliding), a font change and reduced motion snap. While the box is still shorter than its text, the text is laid out for the target height and clipped, so following the cursor never scrolls on the way. Frames are requested only while the height moves. Applies to the composer, Settings additional instructions and the feedback form. The row measure now uses the text style the field paints in: gpui runs measure callbacks outside the element's style, so the composer was sized with the window's default line height and font rather than the chat reading face. --- apps/maple-agent/app/src/ui/text_input.rs | 349 +++++++++++++++++++--- 1 file changed, 307 insertions(+), 42 deletions(-) diff --git a/apps/maple-agent/app/src/ui/text_input.rs b/apps/maple-agent/app/src/ui/text_input.rs index 27bfe6a75..55c9a75c6 100644 --- a/apps/maple-agent/app/src/ui/text_input.rs +++ b/apps/maple-agent/app/src/ui/text_input.rs @@ -11,7 +11,7 @@ pub mod vim; pub(crate) mod vim_actions; use super::popup::{Menu, MenuItem, Placement, Popup}; -use super::{application_vim, spell, theme}; +use super::{application_vim, motion, spell, theme}; use std::collections::VecDeque; use std::ops::Range; use std::sync::Arc; @@ -134,6 +134,9 @@ pub struct TextInput { /// layout does not re-shape unchanged text every frame, and a chat /// face/size change does not keep a stale row count. measure_cache: Option<(SharedString, Pixels, Font, Pixels, Pixels, usize)>, + /// Drawn height of a multi-line input that sizes to its text, eased + /// between row counts (see [`Growth`]). + growth: Option, /// Shaped lines from the last prepaint with the inputs they came /// from; reused while nothing that affects shaping has changed. shape_cache: Option, @@ -286,6 +289,7 @@ impl TextInput { fill_height: false, max_lines: 8, measure_cache: None, + growth: None, shape_cache: None, is_selecting: false, popup: Popup::new(|this| &mut this.popup, cx), @@ -430,10 +434,61 @@ impl TextInput { pub fn set_fill_height(&mut self, fill: bool, cx: &mut Context) { if self.fill_height != fill { self.fill_height = fill; + // Back to sizing by the text: start from its height rather + // than easing from the rows before the fill. + self.growth = None; cx.notify(); } } + /// The height to draw for `rows` measured at `width` in `font`. A + /// new row count under the same measure comes from the text, so the + /// box eases to it from the height on screen; a new width or font + /// (a window resize, the sidebar sliding, a chat font change), the + /// first layout, and reduced motion snap. + fn grow( + &mut self, + rows: usize, + width: Pixels, + font: &Font, + font_size: Pixels, + line_height: Pixels, + reduced: bool, + ) -> Pixels { + let target = (line_height * rows as f32).as_f32(); + match &mut self.growth { + Some(growth) + if growth.width == width + && growth.font == *font + && growth.font_size == font_size + && growth.line_height == line_height => + { + let now = motion::now(); + let distance = px(target - growth.height.value(now)); + let (height, _) = growth.height.step( + target, + motion::resize_duration(distance), + motion::ease_out_cubic, + now, + reduced, + ); + growth.drawn = height; + px(height) + } + _ => { + self.growth = Some(Growth { + height: motion::Tween::new(target), + drawn: target, + width, + font: font.clone(), + font_size, + line_height, + }); + px(target) + } + } + } + pub fn on_enter( mut self, handler: impl Fn(String, &mut Window, &mut Context) + 'static, @@ -2526,6 +2581,22 @@ fn snap_to_char_boundary(text: &str, offset: usize) -> usize { offset } +/// The drawn height of a multi-line input that sizes to its text, and +/// the measure it eases under. Rows that change at the same width and +/// font came from the text and ease; anything else snaps. +struct Growth { + /// Height in pixels, heading for `rows * line_height`. + height: motion::Tween, + /// The height layout drew this frame. While it differs from the + /// target, prepaint asks for another frame and lays the text out for + /// the target. + drawn: f32, + width: Pixels, + font: Font, + font_size: Pixels, + line_height: Pixels, +} + /// Everything `shape_text` was given last time, and what it returned. /// A cursor move or a blink must not re-shape the text: prepaint runs /// on every notify, and shaping is the expensive part of it. @@ -2749,8 +2820,12 @@ impl Element for TextElement { style.min_size.height = window.line_height().into(); return (window.request_layout(style, [], cx), ()); } - // Grow with the wrapped content, one to `max_lines` rows. + // Grow with the wrapped content, one to `max_lines` rows. Layout + // runs the measure outside this element's text style, so take the + // style the text paints in now. let entity = self.input.clone(); + let text_style = window.text_style(); + let line_height = window.line_height(); let layout = window.request_measured_layout(style, move |known, available, window, cx| { let input = entity.read(cx); let text = if input.content.is_empty() { @@ -2758,54 +2833,74 @@ impl Element for TextElement { } else { input.display_text() }; - let line_height = window.line_height(); let width = known.width.or(match available.width { gpui::AvailableSpace::Definite(width) => Some(width), _ => None, }); let cache_width = width.unwrap_or(px(0.)); - let style = window.text_style(); + let style = &text_style; let font_size = style.font_size.to_pixels(window.rem_size()); let font = style.font(); - if let Some(( - cached_text, - cached_width, - cached_font, - cached_font_size, - cached_line_height, - rows, - )) = input.measure_cache.as_ref() - && *cached_text == text - && *cached_width == cache_width - && *cached_font == font - && *cached_font_size == font_size - && *cached_line_height == line_height - { - return size(cache_width, line_height * *rows as f32); - } - let run = TextRun { - len: text.len(), - font: font.clone(), - color: style.color, - background_color: None, - underline: None, - strikethrough: None, + let cached = input + .measure_cache + .as_ref() + .filter( + |( + cached_text, + cached_width, + cached_font, + cached_font_size, + cached_line_height, + _, + )| { + *cached_text == text + && *cached_width == cache_width + && *cached_font == font + && *cached_font_size == font_size + && *cached_line_height == line_height + }, + ) + .map(|cache| cache.5); + let rows = match cached { + Some(rows) => rows, + None => { + let run = TextRun { + len: text.len(), + font: font.clone(), + color: style.color, + background_color: None, + underline: None, + strikethrough: None, + }; + let rows = window + .text_system() + .shape_text(text.clone(), font_size, &[run], width, None) + .map(|lines| { + lines + .iter() + .map(|line| line.wrap_boundaries().len() + 1) + .sum::() + }) + .unwrap_or(1) + .clamp(1, input.max_lines); + let font = font.clone(); + entity.update(cx, |input, _| { + input.measure_cache = + Some((text, cache_width, font, font_size, line_height, rows)); + }); + rows + } }; - let rows = window - .text_system() - .shape_text(text.clone(), font_size, &[run], width, None) - .map(|lines| { - lines - .iter() - .map(|line| line.wrap_boundaries().len() + 1) - .sum::() - }) - .unwrap_or(1) - .clamp(1, input.max_lines); - entity.update(cx, |input, _| { - input.measure_cache = Some((text, cache_width, font, font_size, line_height, rows)); + // An intrinsic-size probe has no width to wrap at; it is not + // the box being drawn, so it leaves the eased height alone. + let Some(width) = width else { + return size(cache_width, line_height * rows as f32); + }; + let reduced = cx.reduce_motion(); + let height = entity.update(cx, |input, _| { + input.grow(rows, width, &font, font_size, line_height, reduced) }); - size(cache_width, line_height * rows as f32) + size(width, height) }); (layout, ()) } @@ -2896,6 +2991,19 @@ impl Element for TextElement { let last_scroll_x = input.scroll_x; let last_scroll_y = input.scroll_y; let keep_cursor_visible = input.keep_cursor_visible; + // While the box eases toward a new row count, lay the text out + // for the height it is heading to and let the box clip the rest, + // so following the cursor never scrolls on the way. + let easing_to = input + .growth + .as_ref() + .filter(|growth| { + multiline && !input.fill_height && growth.drawn != growth.height.target() + }) + .map(|growth| px(growth.height.target())); + if easing_to.is_some() { + window.request_animation_frame(); + } let wrap_width = multiline.then_some(bounds.size.width); // Reuse last frame's shaping when its inputs are unchanged. Lines // are Arc-backed, so the clone is a refcount per line. @@ -2966,7 +3074,7 @@ impl Element for TextElement { // cursor after an edit or a cursor move, keep a wheel scroll put, // and clamp when the content shrinks. let scroll_y = if multiline { - let viewport = bounds.size.height; + let viewport = easing_to.unwrap_or(bounds.size.height); let max_scroll = (layout.height() - viewport).max(px(0.)); let mut sy = last_scroll_y.min(max_scroll).max(px(0.)); if keep_cursor_visible { @@ -3674,6 +3782,163 @@ mod tests { ); } + /// A column like the composer's, so a multi-line field sizes to its + /// text at `width`. + struct GrowHost { + input: Entity, + width: Pixels, + } + + impl Render for GrowHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .w(self.width) + .h(px(400.)) + .flex() + .flex_col() + .child(self.input.clone()) + } + } + + /// A multi-line field holding `text`, drawn with the clock at `start`. + fn growing_field<'a>( + cx: &'a mut TestAppContext, + text: &str, + start: std::time::Instant, + ) -> ( + Entity, + Entity, + &'a mut gpui::VisualTestContext, + ) { + cx.executor().allow_parking(); + cx.update(|cx| cx.set_reduce_motion(false)); + motion::set_test_now(Some(start)); + let input = cx.new(|cx| TextInput::new("", cx).multiline(8)); + input.update(cx, |input, cx| input.set_text(text, cx)); + let (host, cx) = cx.add_window_view(|_window, _cx| GrowHost { + input: input.clone(), + width: px(300.), + }); + cx.run_until_parked(); + (input, host, cx) + } + + fn drawn_height(input: &Entity, cx: &mut gpui::VisualTestContext) -> Pixels { + cx.update(|_window, app| input.read(app).last_bounds.expect("drawn").size.height) + } + + fn line_height(input: &Entity, cx: &mut gpui::VisualTestContext) -> Pixels { + cx.update(|_window, app| { + input + .read(app) + .growth + .as_ref() + .expect("measured") + .line_height + }) + } + + fn set_text_drawn(input: &Entity, text: &str, cx: &mut gpui::VisualTestContext) { + cx.update(|_window, app| input.update(app, |input, cx| input.set_text(text, cx))); + cx.run_until_parked(); + } + + /// Deliver the next frame with the clock at `at`; how many frame + /// requests it answered. + fn frame_at(at: std::time::Instant, cx: &mut gpui::VisualTestContext) -> usize { + motion::set_test_now(Some(at)); + let requests = cx.update(|window, app| window.simulate_next_frame(app)); + cx.run_until_parked(); + requests + } + + #[gpui::test] + fn a_growing_field_eases_to_its_new_rows_and_settles(cx: &mut TestAppContext) { + let start = std::time::Instant::now(); + let (input, _host, cx) = growing_field(cx, "one", start); + let row = line_height(&input, cx); + assert_eq!(drawn_height(&input, cx), row, "the first layout snaps"); + + set_text_drawn(&input, "one\ntwo\nthree", cx); + assert_eq!( + drawn_height(&input, cx), + row, + "new rows ease in from the height on screen" + ); + let duration = motion::resize_duration(row * 2.); + assert!( + frame_at(start + duration / 3, cx) > 0, + "moving asks for frames" + ); + let partway = drawn_height(&input, cx); + assert!(partway > row && partway < row * 3., "partway: {partway:?}"); + assert_eq!( + cx.update(|_window, app| input.read(app).scroll_y), + px(0.), + "the text is laid out for the height it heads to, so the cursor \ + on the third row does not scroll it" + ); + + frame_at(start + duration, cx); + assert_eq!(drawn_height(&input, cx), row * 3.); + assert_eq!( + frame_at(start + duration * 2, cx), + 0, + "settled asks for none" + ); + motion::set_test_now(None); + } + + #[gpui::test] + fn a_growing_field_turns_back_from_the_height_on_screen(cx: &mut TestAppContext) { + let start = std::time::Instant::now(); + let (input, _host, cx) = growing_field(cx, "one", start); + let row = line_height(&input, cx); + + set_text_drawn(&input, "1\n2\n3\n4\n5", cx); + let half = motion::resize_duration(row * 4.) / 2; + frame_at(start + half, cx); + let partway = drawn_height(&input, cx); + assert!(partway > row && partway < row * 5., "partway: {partway:?}"); + + // Clearing after a send while it still grows. + set_text_drawn(&input, "", cx); + assert_eq!(drawn_height(&input, cx), partway, "no jump on reversal"); + frame_at(start + half + std::time::Duration::from_millis(40), cx); + let back = drawn_height(&input, cx); + assert!(back < partway && back > row, "heading back: {back:?}"); + + frame_at(start + motion::PANEL * 4, cx); + assert_eq!(drawn_height(&input, cx), row); + assert_eq!(frame_at(start + motion::PANEL * 5, cx), 0); + motion::set_test_now(None); + } + + #[gpui::test] + fn a_growing_field_snaps_to_a_new_width_and_under_reduced_motion(cx: &mut TestAppContext) { + let start = std::time::Instant::now(); + let (input, host, cx) = growing_field(cx, &"word ".repeat(20), start); + let row = line_height(&input, cx); + let wide = drawn_height(&input, cx); + + // A window resize or the sidebar sliding rewraps the text. + host.update(cx, |host, cx| { + host.width = px(150.); + cx.notify(); + }); + cx.run_until_parked(); + let narrow = drawn_height(&input, cx); + assert!(narrow > wide, "narrower wraps to more rows"); + assert_eq!(frame_at(start, cx), 0, "a width change snaps"); + assert_eq!(drawn_height(&input, cx), narrow); + + cx.update(|_window, app| app.set_reduce_motion(true)); + set_text_drawn(&input, "one", cx); + assert_eq!(drawn_height(&input, cx), row, "reduced motion snaps"); + assert_eq!(frame_at(start, cx), 0); + motion::set_test_now(None); + } + #[gpui::test] fn test_cursor_moves_never_leave_the_content(cx: &mut TestAppContext) { cx.executor().allow_parking(); From c8fcf994aff2157662bf8df69ae8f2afe452f96c Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:07:27 -0800 Subject: [PATCH 14/29] Agent: composer expands and shrinks smoothly Expand and shrink ease the composer region's height between the height of its text and expanded over PANEL with ease-out cubic. A second click mid-flight turns back from the height on screen. Both ends come from bounds measured the frame before, so the box starts and lands exactly on its resting layout; the field fills the box throughout, so typed text never moves, and the transcript keeps its tail. In the empty state the heading and the privacy line fold away and fade with the composer, so the column does not jump. The maximize and minimize icons cross-fade and scale over TOGGLE, and the button names its action to assistive technology. Reduced motion switches at once, as before. --- apps/maple-agent/app/src/ui/chat/composer.rs | 318 ++++++++++++++++++- apps/maple-agent/app/src/ui/chat/mod.rs | 198 +++++++++--- apps/maple-agent/app/src/ui/text_input.rs | 11 + 3 files changed, 472 insertions(+), 55 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index aab8047a8..8e2785ce4 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -89,6 +89,42 @@ fn mode_note(mode: AgentPermissionMode) -> &'static str { } } +/// Heights the composer expand/shrink transition eases between, recorded +/// as each frame is laid out so the next one can start from the screen. +#[derive(Clone, Copy, Default)] +pub(super) struct ComposerFrame { + /// The composer region: cards, composer box, palette, padding. + pub(super) region: Option, + /// The height the region takes when expanded. + pub(super) room: Option, + /// The empty state's title and privacy line at rest, which fold away + /// while the composer expands there. + pub(super) title: Option, + pub(super) privacy: Option, +} + +/// Stores a measured element's bounds in a [`ComposerFrame`]. +pub(super) type Record = fn(&mut ComposerFrame, gpui::Bounds, &Window); + +/// A paint-free canvas over its parent that hands the parent's bounds to +/// `record` at prepaint, for the next frame's composer transition. +pub(super) fn measure( + frame: &std::rc::Rc>, + record: Record, +) -> impl IntoElement { + let frame = frame.clone(); + gpui::canvas( + move |bounds, window, _| { + let mut measured = frame.get(); + record(&mut measured, bounds, window); + frame.set(measured); + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0() +} + impl ChatScreen { pub(super) fn render_header( &self, @@ -1220,6 +1256,49 @@ impl ChatScreen { (row, false) } + /// While the composer eases between the height of its text and + /// expanded: how far toward expanded it is, and the region's height + /// for this frame, from heights measured last frame. `None` at rest; + /// once a transition lands the field goes back to its resting fill. + pub(super) fn composer_resize_frame( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(f32, gpui::Pixels)> { + let transition = self.composer_resize?; + let now = motion::now(); + let frame = self.composer_frame.get(); + let sample = (!cx.reduce_motion() && transition.is_moving(now)) + .then(|| { + let progress = transition.value(now); + // The field fills the box throughout, so the box at the + // height of its text is the region less the field's spare + // height. Its row is never shorter than the expand button + // beside it (`size_6`). + let (drawn, text) = self.composer.as_ref()?.read(cx).drawn_and_text_height()?; + let row = text.max(window.rem_size() * 1.5); + let natural = frame.region? - (drawn - row).max(gpui::px(0.)); + let room = frame.room?; + Some((progress, natural + (room - natural) * progress)) + }) + .flatten(); + if sample.is_some() { + window.request_animation_frame(); + return sample; + } + self.composer_resize = None; + let expanded = self.composer_expanded; + if let Some(composer) = self.composer.clone() { + composer.update(cx, |input, cx| input.set_fill_height(expanded, cx)); + } + None + } + + /// The composer box lays out expanded: at rest there, or on the way. + pub(super) fn composer_fills(&self) -> bool { + self.composer_expanded || self.composer_resize.is_some() + } + pub(super) fn render_composer(&mut self, window: &mut Window, cx: &mut Context) -> Div { let running = self.is_run_active(); // The first send's create keeps the composer's text on show and @@ -1247,6 +1326,9 @@ impl ChatScreen { let show_stop = running && !self.booting && !pending_create && !drafting; let queue_chips = self.render_queue(cx); let expanded = self.composer_expanded; + // Layout follows the box while it eases; the icon and tooltip + // name the state it is heading to. + let fills = self.composer_fills(); let composer = self.composer.clone(); let vim_badge = composer .as_ref() @@ -1308,7 +1390,7 @@ impl ChatScreen { .flex_col() .relative() .debug_selector(|| "composer-box".to_string()) - .when(expanded, |container| container.flex_1().min_h_0()) + .when(fills, |container| container.flex_1().min_h_0()) .rounded(theme::RADIUS_XL) .bg(gpui::rgb(theme::bg_app())) .border_1() @@ -1441,20 +1523,27 @@ impl ChatScreen { .px_4() .pt_4() .pb_2() - .when(expanded, |row| row.flex_1().min_h_0()) + .when(fills, |row| row.flex_1().min_h_0()) .child( crate::ui::typography::chat_reading(div()) .flex_1() .min_w_0() - .when(expanded, |cell| cell.h_full()) + .when(fills, |cell| cell.h_full()) .text_color(gpui::rgb(theme::text_primary())) // The first send remains visibly editable until // task creation settles; keep its real focus path. .children(composer), ) - .child( + .child({ + let label = if expanded { + tr("chat-shrink-composer") + } else { + tr("chat-expand-composer") + }; div() .id("composer-expand") + .role(gpui::Role::Button) + .aria_label(label.clone()) .size_6() .flex() .items_center() @@ -1469,23 +1558,18 @@ impl ChatScreen { ) }) .hover(|style| style.cursor_pointer()) - .tooltip(widgets::tooltip( - if expanded { - tr("chat-shrink-composer") - } else { - tr("chat-expand-composer") - }, - None, - )) + .tooltip(widgets::tooltip(label, None)) .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_composer_expanded(cx); })) - .child(icon( - if expanded { "minimize-2" } else { "maximize-2" }, + .child(motion::icon_swap( + "composer-expand-icon", + expanded, + ["maximize-2", "minimize-2"], px(14.), theme::text_muted(), - )), - ), + )) + }), ) .child( div() @@ -1691,3 +1775,205 @@ fn chip( .child(div().whitespace_nowrap().child(label)) .when(chevron, |el| el.child(icon("chevron-down", px(14.), color))) } + +#[cfg(test)] +mod resize_tests { + use super::*; + use gpui::{Entity, TestAppContext, VisualTestContext}; + use maple_agent::agent::AgentTimelineItem; + use std::time::{Duration, Instant}; + + struct Host { + chat: Entity, + } + + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .w(px(1200.)) + .h(px(800.)) + .flex() + .flex_col() + .child(self.chat.clone()) + } + } + + /// A chat whose transcript holds `rows` messages and follows its + /// tail; none shows the empty state. + fn chat_window( + cx: &mut TestAppContext, + rows: usize, + ) -> (Entity, &mut VisualTestContext) { + cx.executor().allow_parking(); + let backend = { + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + Arc::new( + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()) + .expect("backend"), + ) + }; + let chat = cx.new(|cx| { + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + chat.selected_session = Some("s1".to_string()); + chat.booting = false; + chat.trust_prompts = false; + chat.sidebar_collapsed = true; + chat.sidebar + .update(cx, |sidebar, _| sidebar.reset_persisted_for_test()); + chat.replace_timeline( + (0..rows) + .map(|index| AgentTimelineItem { + label: None, + persisted: None, + live: None, + truncated: None, + revision: 0, + id: format!("m{index}"), + item_type: "message".to_string(), + role: None, + title: None, + text: Some(format!("Message {index}\n\n{}", "lorem ipsum ".repeat(30))), + status: None, + input: None, + output: None, + created_ms: 0, + merge: "replace".to_string(), + }) + .collect(), + ); + chat + }); + let (_host, cx) = cx.add_window_view(|_window, _cx| Host { chat: chat.clone() }); + cx.simulate_resize(gpui::size(px(1200.), px(800.))); + cx.run_until_parked(); + (chat, cx) + } + + fn composer_height(cx: &mut VisualTestContext) -> gpui::Pixels { + cx.debug_bounds("composer-box") + .expect("composer") + .size + .height + } + + /// Space between the newest transcript row and the composer: constant + /// while the transcript keeps its tail on screen. + fn tail_gap(cx: &mut VisualTestContext) -> gpui::Pixels { + let newest = cx + .debug_bounds("timeline-row-m39") + .expect("the newest row is on screen"); + cx.debug_bounds("composer-box").expect("composer").top() - newest.bottom() + } + + fn toggle(chat: &Entity, cx: &mut VisualTestContext) { + chat.update(cx, |chat, cx| chat.toggle_composer_expanded(cx)); + cx.run_until_parked(); + } + + fn frame_at(at: Instant, cx: &mut VisualTestContext) { + motion::set_test_now(Some(at)); + cx.update(|window, app| window.simulate_next_frame(app)); + cx.run_until_parked(); + } + + #[gpui::test] + fn expanding_the_composer_eases_reverses_and_keeps_the_tail(cx: &mut TestAppContext) { + let start = Instant::now(); + motion::set_test_now(Some(start)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (chat, cx) = chat_window(cx, 40); + let rest = composer_height(cx); + let pinned = tail_gap(cx); + + toggle(&chat, cx); + assert_eq!( + composer_height(cx), + rest, + "starts from the height on screen" + ); + frame_at(start + motion::PANEL - Duration::from_millis(1), cx); + let landing = composer_height(cx); + assert_eq!(tail_gap(cx), pinned, "the transcript keeps its tail"); + frame_at(start + motion::PANEL, cx); + let expanded = composer_height(cx); + assert!(expanded > rest * 2., "expanded: {expanded:?}"); + assert!( + (expanded - landing).abs() < px(1.), + "lands on the resting layout: {landing:?} then {expanded:?}" + ); + assert_eq!(tail_gap(cx), pinned, "the transcript keeps its tail"); + + // Shrink, then expand again halfway down. + let shrink = start + motion::PANEL * 2; + motion::set_test_now(Some(shrink)); + toggle(&chat, cx); + frame_at(shrink + motion::PANEL / 2, cx); + let partway = composer_height(cx); + assert!(partway > rest && partway < expanded, "partway: {partway:?}"); + assert_eq!(tail_gap(cx), pinned, "the tail stays put mid-flight"); + toggle(&chat, cx); + assert_eq!( + composer_height(cx), + partway, + "a reversal starts where it is" + ); + frame_at(shrink + motion::PANEL / 2 + Duration::from_millis(30), cx); + assert!(composer_height(cx) > partway, "heading back up"); + frame_at(shrink + motion::PANEL * 2, cx); + assert_eq!(composer_height(cx), expanded); + assert!(chat.read_with(cx, |chat, _| chat.composer_resize.is_none())); + + // Reduced motion switches at once. + cx.update(|_window, app| app.set_reduce_motion(true)); + toggle(&chat, cx); + assert_eq!(composer_height(cx), rest); + assert_eq!(tail_gap(cx), pinned); + motion::set_test_now(None); + } + + #[gpui::test] + fn expanding_from_the_empty_state_folds_the_hero_without_a_jump(cx: &mut TestAppContext) { + let start = Instant::now(); + motion::set_test_now(Some(start)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (chat, cx) = chat_window(cx, 0); + let rest = cx.debug_bounds("composer-box").expect("composer"); + + toggle(&chat, cx); + assert_eq!( + cx.debug_bounds("composer-box"), + Some(rest), + "the heading and the note are still there on the first frame" + ); + frame_at(start + motion::PANEL / 2, cx); + let partway = composer_height(cx); + assert!(partway > rest.size.height, "partway: {partway:?}"); + frame_at(start + motion::PANEL - Duration::from_millis(1), cx); + let landing = cx.debug_bounds("composer-box").expect("composer"); + frame_at(start + motion::PANEL, cx); + let expanded = cx.debug_bounds("composer-box").expect("composer"); + assert!( + (expanded.top() - landing.top()).abs() < px(1.), + "{landing:?} {expanded:?}" + ); + assert!( + (expanded.size.height - landing.size.height).abs() < px(1.), + "{landing:?} {expanded:?}" + ); + + let shrink = start + motion::PANEL * 2; + motion::set_test_now(Some(shrink)); + toggle(&chat, cx); + assert_eq!(cx.debug_bounds("composer-box"), Some(expanded)); + frame_at(shrink + motion::PANEL - Duration::from_millis(1), cx); + let landing = cx.debug_bounds("composer-box").expect("composer"); + frame_at(shrink + motion::PANEL, cx); + assert!( + (rest.top() - landing.top()).abs() < px(1.), + "{landing:?} {rest:?}" + ); + assert_eq!(cx.debug_bounds("composer-box"), Some(rest)); + motion::set_test_now(None); + } +} diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 912c83b70..58fdacef9 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -650,6 +650,11 @@ pub struct ChatScreen { mcp_enabled_count: usize, /// Composer fills the pane (fullscreen editing). composer_expanded: bool, + /// How far the composer is from the height of its text (0) toward + /// expanded (1) while it eases between them; `None` at rest. + composer_resize: Option, + /// Heights that transition eases between, measured last frame. + composer_frame: std::rc::Rc>, /// Latest todo list from the selected task, pinned above the composer /// while any entry is unfinished. A finished list stays in the /// transcript. @@ -1516,6 +1521,8 @@ impl ChatScreen { session_mcp: Vec::new(), mcp_enabled_count: 0, composer_expanded: false, + composer_resize: None, + composer_frame: std::rc::Rc::default(), web_enabled: true, default_web_enabled: settings.default_web_enabled, permission_mode: settings.new_task_permission_mode.unwrap_or_default(), @@ -4107,10 +4114,36 @@ impl ChatScreen { } fn toggle_composer_expanded(&mut self, cx: &mut Context) { + let now = motion::now(); + let from = self + .composer_resize + .map(|transition| transition.value(now)) + .unwrap_or(if self.composer_expanded { 1. } else { 0. }); self.composer_expanded = !self.composer_expanded; let expanded = self.composer_expanded; + let to = if expanded { 1. } else { 0. }; + let distance = (to - from).abs(); + // Eases between heights measured on screen, so it needs a frame + // drawn first; reduced motion switches at once. + let frame = self.composer_frame.get(); + let measured = frame.region.is_some() && frame.room.is_some(); + self.composer_resize = + (!cx.reduce_motion() && measured && distance > f32::EPSILON).then(|| { + let mut transition = motion::Tween::new(from); + transition.set( + to, + motion::PANEL.mul_f32(distance), + motion::ease_out_cubic, + now, + false, + ); + transition + }); + // The field fills the box while it moves, so typed text stays put + // and the box's text height can be read off the field. + let fill = self.composer_fills(); if let Some(composer) = self.composer.clone() { - composer.update(cx, |input, cx| input.set_fill_height(expanded, cx)); + composer.update(cx, |input, cx| input.set_fill_height(fill, cx)); } cx.notify(); } @@ -7160,8 +7193,9 @@ impl Render for ChatScreen { let empty = self.shows_empty_state(); let sidebar_expansion = self.sidebar_expansion(window, cx); let rise = gpui::point(px(0.), motion::RISE); + let composer_resize = self.composer_resize_frame(window, cx); let main = if empty { - self.render_empty_state(window, cx) + self.render_empty_state(composer_resize, window, cx) } else { div() .flex() @@ -7209,12 +7243,18 @@ impl Render for ChatScreen { .mx_auto() .px_6() .pb_4() - .when(self.composer_expanded, |wrap| { - wrap.flex_none() + .map(|wrap| match composer_resize { + // Easing between its text's height and expanded. + Some((_, height)) => { + wrap.flex_none().h(height).min_h_0().flex().flex_col() + } + None if self.composer_expanded => wrap + .flex_none() .h(gpui::relative(0.7)) .min_h_0() .flex() - .flex_col() + .flex_col(), + None => wrap, }) // The composer, its cards and the palette keep their // own wheel; the space beside them scrolls the @@ -7233,8 +7273,21 @@ impl Render for ChatScreen { })) .child(self.render_composer(window, cx)) .children(self.render_slash_palette(cx)) - .children(self.render_persistence_failure(cx)), + .children(self.render_persistence_failure(cx)) + .child(composer::measure( + &self.composer_frame, + |frame, bounds, _| { + frame.region = Some(bounds.size.height); + }, + )), ) + // Expanded, the composer region takes 70% of this column. + .child(composer::measure( + &self.composer_frame, + |frame, bounds, _| { + frame.room = Some(bounds.size.height * 0.7); + }, + )) }; div() .key_context(self.application_vim_context()) @@ -7851,8 +7904,73 @@ impl ChatScreen { /// Hero layout for a task with no messages: display heading, composer, /// and privacy note centered in the pane (mirrors EmptyAgentState). - fn render_empty_state(&mut self, window: &mut Window, cx: &mut Context) -> Div { + /// Expanded, the composer fills the pane; while it eases there and + /// back (`composer_resize`), the heading and the note fold away and + /// fade with it so the column never jumps. + fn render_empty_state( + &mut self, + composer_resize: Option<(f32, gpui::Pixels)>, + window: &mut Window, + cx: &mut Context, + ) -> Div { let expanded = self.composer_expanded; + let fills = self.composer_fills(); + let hero = match composer_resize { + Some((progress, _)) => 1. - progress, + None if expanded => 0., + None => 1., + }; + let measured = self.composer_frame.get(); + // At rest a block records its height; on the way it is drawn at + // `hero` of that height and opacity. Each block carries the + // spacing beside it as its content's margin (padding would not + // fold), so nothing is left behind. + let fold = |block: Div, rest: Option, record: composer::Record| { + if composer_resize.is_some() { + block + .flex_none() + .h(rest.unwrap_or_default() * hero) + .overflow_hidden() + .opacity(hero) + } else { + block + .relative() + .child(composer::measure(&self.composer_frame, record)) + } + }; + let title = (hero > 0.).then(|| { + fold( + div().w_full().flex().flex_col().items_center().child( + div() + // Its own space and the gap to the composer. + .mb_12() + .font_family(crate::assets::FONT_DISPLAY) + .text_size(px(36.)) + .line_height(px(48.)) + .text_color(gpui::rgb(theme::display_text())) + .child(tr("chat-empty-title")), + ), + measured.title, + |frame, bounds, _| frame.title = Some(bounds.size.height), + ) + }); + let privacy = (hero > 0.).then(|| { + fold( + div().w_full().flex().justify_center().child( + div() + .mt_6() + .flex() + .items_center() + .gap_1() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(icon("lock", px(12.), theme::text_muted())) + .child(tr("chat-empty-private")), + ), + measured.privacy, + |frame, bounds, _| frame.privacy = Some(bounds.size.height), + ) + }); let body = div() .flex() .flex_col() @@ -7862,7 +7980,14 @@ impl ChatScreen { .items_center() .justify_center() .px_6() - .when(expanded, |pane| pane.py_6()) + .when(fills, |pane| pane.py_6()) + // Expanded, the composer region takes the pane less its padding. + .child(composer::measure( + &self.composer_frame, + |frame, bounds, window| { + frame.room = Some(bounds.size.height - window.rem_size() * 3.); + }, + )) .child( div() .w_full() @@ -7871,48 +7996,43 @@ impl ChatScreen { .flex() .flex_col() .items_center() - .gap_6() - .when(expanded, |column| column.h_full().min_h_0()) - .when(!expanded, |column| { - column.child( - div() - .mb_6() - .font_family(crate::assets::FONT_DISPLAY) - .text_size(px(36.)) - .line_height(px(48.)) - .text_color(gpui::rgb(theme::display_text())) - .child(tr("chat-empty-title")), - ) - }) + .when(fills, |column| column.h_full().min_h_0().justify_center()) + .children(title) .child( div() .relative() .w_full() - .when(expanded, |wrap| wrap.flex_1().min_h_0().flex().flex_col()) + .map(|wrap| match composer_resize { + // Grows into the free space up to this + // frame's height. + Some((_, height)) => { + wrap.flex_1().min_h_0().max_h(height).flex().flex_col() + } + None if expanded => wrap.flex_1().min_h_0().flex().flex_col(), + None => wrap, + }) .child(self.render_composer(window, cx)) - .children(self.render_slash_palette(cx)), + .children(self.render_slash_palette(cx)) + .child(composer::measure( + &self.composer_frame, + |frame, bounds, _| { + frame.region = Some(bounds.size.height); + }, + )), ) .when( self.awaiting_first_token && self.is_run_active(), - |column| column.child(render_waiting_indicator()), + |column| column.child(render_waiting_indicator().mt_6()), ) - .when(!expanded, |column| { - column.child( - div() - .flex() - .items_center() - .gap_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(icon("lock", px(12.), theme::text_muted())) - .child(tr("chat-empty-private")), - ) - }) + .children(privacy) .when_some(self.runtime_error.clone(), |column, error| { - column.child(widgets::banner(theme::status_error()).child(error)) + column.child(widgets::banner(theme::status_error()).mt_6().child(error)) }) - .children(self.render_persistence_failure(cx)) - .children(self.render_notice(cx)), + .children( + self.render_persistence_failure(cx) + .map(|failure| failure.mt_6()), + ) + .children(self.render_notice(cx).map(|notice| notice.mt_6())), ); div() .flex() diff --git a/apps/maple-agent/app/src/ui/text_input.rs b/apps/maple-agent/app/src/ui/text_input.rs index 55c9a75c6..87f0f1531 100644 --- a/apps/maple-agent/app/src/ui/text_input.rs +++ b/apps/maple-agent/app/src/ui/text_input.rs @@ -441,6 +441,17 @@ impl TextInput { } } + /// The field's height as drawn last frame, and the height its text + /// needs (one to `max_lines` rows). They differ while it fills its + /// parent; the expanded composer eases back to the text's height. + pub fn drawn_and_text_height(&self) -> Option<(Pixels, Pixels)> { + let (layout, bounds) = (self.last_layout.as_ref()?, self.last_bounds.as_ref()?); + let text = layout + .height() + .min(layout.line_height * self.max_lines as f32); + Some((bounds.size.height, text)) + } + /// The height to draw for `rows` measured at `width` in `font`. A /// new row count under the same measure comes from the text, so the /// box eases to it from the height on screen; a new width or font From dfe93fed1c70f02261362352413967751f08ac4d Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:07:30 -0800 Subject: [PATCH 15/29] Agent: Send and Stop cross-fade; attached files settle in The composer's main control cross-fades between Send and Stop over TOGGLE: the fills fade and the arrow scales, while the handler, role and accessible name switch on the frame the state changes. The send fill eases between disabled and ready instead of snapping its opacity. A file joining the draft fades and rises 6 px into place over ENTER; removing one is immediate. Reduced motion settles each at once. --- apps/maple-agent/app/src/ui/chat/composer.rs | 118 +++++++++++++------ apps/maple-agent/app/src/ui/chat/mod.rs | 6 + apps/maple-agent/docs/motion.md | 1 + 3 files changed, 92 insertions(+), 33 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 8e2785ce4..355fab789 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -1329,6 +1329,26 @@ impl ChatScreen { // Layout follows the box while it eases; the icon and tooltip // name the state it is heading to. let fills = self.composer_fills(); + // Send ↔ Stop and the send fill's enabled state ease over TOGGLE. + let (send_stop, send_ready) = { + let now = motion::now(); + let reduced = cx.reduce_motion(); + let step = |tween: &mut motion::Tween, on: bool| { + tween.step( + if on { 1. } else { 0. }, + motion::TOGGLE, + motion::ease_out_cubic, + now, + reduced, + ) + }; + let (stop, stop_moving) = step(&mut self.send_stop, show_stop); + let (ready, ready_moving) = step(&mut self.send_ready, can_send); + if stop_moving || ready_moving { + window.request_animation_frame(); + } + (stop, ready) + }; let composer = self.composer.clone(); let vim_badge = composer .as_ref() @@ -1443,7 +1463,7 @@ impl ChatScreen { container.child(div().flex().flex_wrap().gap_2().px_4().pt_4().children( drafts.iter().map(|attachment| { let id = attachment.id; - div() + let chip = div() .relative() .max_w(px(260.)) .h_16() @@ -1512,7 +1532,15 @@ impl ChatScreen { this.remove_attachment(id, cx); })) .child(icon("x", px(10.), theme::text_primary())), - ) + ); + // A file joining the draft settles in; removing + // one is immediate. + motion::slide_in( + chip, + ("draft-attachment", id as usize), + motion::ENTER, + gpui::point(px(0.), motion::RISE), + ) }), )) }) @@ -1623,51 +1651,75 @@ impl ChatScreen { } else { tr("chat-send") }; + // The fills cross-fade and the arrow scales while + // the handler, role and label switch at once. + let send_layer = (send_stop < 1.).then(|| { + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(gpui::linear_gradient( + 180., + gpui::linear_color_stop(gpui::rgb(theme::send_top()), 0.), + gpui::linear_color_stop(gpui::rgb(theme::send_bottom()), 1.), + )) + .opacity((0.4 + 0.6 * send_ready) * (1. - send_stop)) + .child(if disabled { + spinner("send-booting", px(16.), theme::on_accent()) + } else { + let scale = 1. - 0.4 * send_stop; + icon("arrow-up", px(16.), theme::on_accent()) + .with_transformation(gpui::Transformation::scale( + gpui::size(scale, scale), + )) + .into_any_element() + }) + }); + let stop_layer = (send_stop > 0.).then(|| { + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(gpui::rgb(theme::status_error())) + .opacity(send_stop) + .child( + div() + .size_3() + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_app())), + ) + }); div() .id(button_id) .debug_selector(move || button_id.to_string()) .role(gpui::Role::Button) .aria_label(button_label.clone()) + .relative() .size_8() - .flex() - .items_center() - .justify_center() .rounded_full() + .children(send_layer) + .children(stop_layer) .when(show_stop, |el| { - el.bg(gpui::rgb(theme::status_error())) - .hover(|style| style.opacity(0.85).cursor_pointer()) + el.hover(|style| style.opacity(0.85).cursor_pointer()) .active(|style| style.opacity(0.7)) .tooltip(widgets::tooltip(tr("chat-stop-run"), None)) .on_click(cx.listener(|this, _event, _window, cx| { this.stop(cx); })) - .child( - div() - .size_3() - .rounded(theme::RADIUS_SM) - .bg(gpui::rgb(theme::bg_app())), - ) }) - .when(!show_stop, |el| { - el.bg(gpui::linear_gradient( - 180., - gpui::linear_color_stop(gpui::rgb(theme::send_top()), 0.), - gpui::linear_color_stop(gpui::rgb(theme::send_bottom()), 1.), - )) - .when(!can_send, |el| el.opacity(0.4)) - .when(can_send, |el| { - el.hover(|style| style.opacity(0.9).cursor_pointer()) - .active(|style| style.opacity(0.75)) - .tooltip(widgets::tooltip(button_label.clone(), Some("↵"))) - .on_click(cx.listener(|this, _event, _window, cx| { - this.send_inner(cx); - })) - }) - .child(if disabled { - spinner("send-booting", px(16.), theme::on_accent()) - } else { - icon("arrow-up", px(16.), theme::on_accent()).into_any_element() - }) + .when(!show_stop && can_send, |el| { + el.hover(|style| style.opacity(0.9).cursor_pointer()) + .active(|style| style.opacity(0.75)) + .tooltip(widgets::tooltip(button_label.clone(), Some("↵"))) + .on_click(cx.listener(|this, _event, _window, cx| { + this.send_inner(cx); + })) }) }), ) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 58fdacef9..ab9fa3737 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -655,6 +655,10 @@ pub struct ChatScreen { composer_resize: Option, /// Heights that transition eases between, measured last frame. composer_frame: std::rc::Rc>, + /// Send (0) or Stop (1) on the composer's main control. + send_stop: motion::Tween, + /// The send fill, disabled (0) or ready (1). + send_ready: motion::Tween, /// Latest todo list from the selected task, pinned above the composer /// while any entry is unfinished. A finished list stays in the /// transcript. @@ -1523,6 +1527,8 @@ impl ChatScreen { composer_expanded: false, composer_resize: None, composer_frame: std::rc::Rc::default(), + send_stop: motion::Tween::new(0.), + send_ready: motion::Tween::new(0.), web_enabled: true, default_web_enabled: settings.default_web_enabled, permission_mode: settings.new_task_permission_mode.unwrap_or_default(), diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index 37d87dbff..59a4fffef 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -62,6 +62,7 @@ shared 15 fps `ticker` clock and pause in background windows. | Live transcript rows (sent message, reply, tool card, error, stop notice) | fade and 6 px rise, `ENTER` | — | | Streaming text | grows in place, no animation | — | | Cards above the composer, queued messages, send preview | fade and rise, `ENTER` | immediate | +| Files attached to the draft | fade and 6 px rise, `ENTER` | immediate | | Composer and multi-line fields | height eases to the new line count, `resize_duration` | same | | Composer expand / shrink | height eases, `PANEL`; maximize and minimize icons cross-fade and scale, `TOGGLE` | same, reversed | | Send ↔ Stop | icons cross-fade and scale, `TOGGLE` | same | From 25d7c76f4eb5dabc2488c716b3c48b525e029d39 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:19:25 -0800 Subject: [PATCH 16/29] Agent: settle the merged motion slices Keep the waiting dots' spacing now that they fade in, drop the unused Animated::enter_from, and document swap_in and the menu entrance. --- apps/maple-agent/app/src/ui/chat/mod.rs | 2 +- apps/maple-agent/app/src/ui/motion.rs | 18 +++--------------- apps/maple-agent/docs/motion.md | 14 ++++++++++---- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index ab9fa3737..be8ad4373 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -8028,7 +8028,7 @@ impl ChatScreen { ) .when( self.awaiting_first_token && self.is_run_active(), - |column| column.child(render_waiting_indicator().mt_6()), + |column| column.child(div().mt_6().child(render_waiting_indicator())), ) .children(privacy) .when_some(self.runtime_error.clone(), |column, error| { diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 62413806b..8c01471d0 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -482,8 +482,8 @@ impl Element for Shifted { /// An element rebuilt each frame from a value that eases toward `target` /// and retargets from wherever it is. The value lives in element state /// under `id`, so render code needs no view state. It starts at `target`, -/// so nothing animates on mount, unless [`Animated::enter_from`] says -/// otherwise. `build` runs at layout time; keep it cheap. +/// so nothing animates on mount. `build` runs at layout time; keep it +/// cheap. pub fn animated( id: impl Into, target: f32, @@ -496,7 +496,6 @@ pub fn animated( target, duration, easing, - initial: None, build: Box::new(build), } } @@ -506,18 +505,9 @@ pub struct Animated { target: f32, duration: Duration, easing: fn(f32) -> f32, - initial: Option, build: Box AnyElement>, } -impl Animated { - /// Start from `value` the first time this element appears. - pub fn enter_from(mut self, value: f32) -> Self { - self.initial = Some(value); - self - } -} - // Keyed apart from other element state that may share the id. struct AnimatedValue(Tween); @@ -553,9 +543,7 @@ impl Element for Animated { let (value, moving) = window.with_element_state( id.expect("animated elements have an id"), |state: Option, _| { - let mut state = state.unwrap_or_else(|| { - AnimatedValue(Tween::new(self.initial.unwrap_or(self.target))) - }); + let mut state = state.unwrap_or_else(|| AnimatedValue(Tween::new(self.target))); let frame = state .0 .step(self.target, self.duration, self.easing, now, reduced); diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index 59a4fffef..2b6b517ec 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -81,10 +81,16 @@ appear only after 300 ms, so fast loads never flash a spinner. the composer) keep a `Tween` in their state, sample it in `render`, and call `window.request_animation_frame()` only while it moves. - `animated(id, target, ...)` keeps a `Tween` in element state for render code - that owns no state (chevrons, switches, icon swaps). It does not animate on - mount unless asked with `enter_from`. -- `fade_in` and `rise_in` are one-shot entrances keyed by element id. A new - id replays them; use that for content swaps (a Settings section, a task). + that owns no state (chevrons, switches, icon swaps). It never animates on + mount. +- `fade_in`, `rise_in` and `slide_in` are one-shot entrances keyed by element + id; a new id replays them. `swap_in(element, id, key)` fades content in + whenever `key` changes but draws its first appearance at once, for content + that replaces other content in one place (the window's screen, a Settings + section). +- Menus open from their trigger: the panel's fade and slide are drawn inside + the deferred draw, because opacity does not reach into `deferred()`. + Keyboard highlight moves inside a menu settle at once; pointer hover eases. - Transcript rows sit in a `gpui::list`, whose items lose element state when they scroll away. Their entrance is driven by the arrival time kept in view state, so a row animates once and never replays. From f0ab28e3ede9db3363177471dcd03dc2fda00def Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:29:40 -0800 Subject: [PATCH 17/29] Agent: new rows open at the tail and the sent message lands in place A row that arrives while the transcript follows its tail opens from nothing to its height over 180 ms, so the rows above glide up instead of jumping; scrolled up, it still only fades and rises. A reply that streams meanwhile ends at its true height. The sent bubble rises in from the moment it shows, its status line only fades, and the status shares one slot with the waiting dots, so the real message takes over the bubble's place with nothing jumping. A finished tool call drops its running state at once and fades in the outcome rather than overlapping both labels. --- apps/maple-agent/app/src/ui/chat/mod.rs | 68 +- apps/maple-agent/app/src/ui/chat/sending.rs | 144 ++-- .../maple-agent/app/src/ui/chat/transcript.rs | 635 +++++++++++++++--- 3 files changed, 671 insertions(+), 176 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index be8ad4373..626f82796 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -6122,7 +6122,11 @@ impl ChatScreen { None => { let index = self.timeline.len(); self.timeline_index.insert(item.id.clone(), (index, 0)); - self.row_motion.arrive(&item.id, motion::now()); + self.row_motion.arrive( + &item.id, + motion::now(), + self.list_state.is_following_tail(), + ); self.timeline.push(item); self.list_state.splice(index..index, 1); if index == 0 { @@ -6184,6 +6188,38 @@ impl ChatScreen { true } + /// Whether the slot under the transcript shows something: the message + /// being sent or the waiting dots. + fn waiting_slot_shown(&self) -> bool { + self.has_send_feedback() || (self.awaiting_first_token && self.is_run_active()) + } + + /// The slot under the transcript: the message being sent with its + /// status line, or the waiting dots, both `WAITING_ROW_HEIGHT` tall so + /// one taking over from the other moves nothing. + pub(super) fn render_under_transcript( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> [Option; 2] { + let preview = self.render_send_preview(window, cx); + let dots = self.awaiting_first_token && self.is_run_active() && !self.has_send_feedback(); + self.row_motion.slot_on_screen = preview.is_some() || dots; + [ + preview.map(IntoElement::into_any_element), + // Same gutter as transcript text so the dots line up. + dots.then(|| { + div() + .w_full() + .max_w(CONTENT_WIDTH) + .mx_auto() + .px_6() + .child(render_waiting_indicator()) + .into_any_element() + }), + ] + } + /// One timeline item arrived for the selected session. fn apply_incoming_item( &mut self, @@ -6198,11 +6234,21 @@ impl ChatScreen { // work; the summary checks read the stored item after the merge. let completed = item.status.as_deref() == Some("completed"); let is_tool = matches!(item.item_type.as_str(), "tool" | "toolCall"); + let new_row = !self.timeline_index.contains_key(&item.id); self.load_attachment_images_for(&item, cx); let plan = plan_entries(&item); let Some(index) = self.apply_timeline_item(session_id, item) else { return; }; + // The waiting row (or the sent message's status) leaves the slot + // under the transcript as this row arrives: the row opens from that + // height, so nothing above it jumps. + if new_row && self.row_motion.slot_on_screen && !self.waiting_slot_shown() { + self.row_motion.slot_on_screen = false; + let id = self.timeline[index].id.clone(); + self.row_motion + .open_from_more(&id, transcript::WAITING_ROW_HEIGHT); + } if let Some(plan) = plan { self.update_live_plan(plan, cx); } @@ -7201,6 +7247,8 @@ impl Render for ChatScreen { let rise = gpui::point(px(0.), motion::RISE); let composer_resize = self.composer_resize_frame(window, cx); let main = if empty { + // No slot under a transcript here; rows opening from it start at 0. + self.row_motion.slot_on_screen = false; self.render_empty_state(composer_resize, window, cx) } else { div() @@ -7213,20 +7261,10 @@ impl Render for ChatScreen { // takes scrolls the transcript. .on_scroll_wheel(cx.listener(Self::scroll_transcript_from_pane)) .child(self.render_transcript(window, cx)) - .children(self.render_send_preview()) - .when( - self.awaiting_first_token && self.is_run_active() && !self.has_send_feedback(), - |main| { - // Same gutter as transcript text so the dots line up - main.child( - div() - .w_full() - .max_w(CONTENT_WIDTH) - .mx_auto() - .px_6() - .child(render_waiting_indicator()), - ) - }, + .children( + self.render_under_transcript(window, cx) + .into_iter() + .flatten(), ) .when_some(self.current_question(), |container, question| { let input = self.pending_question_input.clone(); diff --git a/apps/maple-agent/app/src/ui/chat/sending.rs b/apps/maple-agent/app/src/ui/chat/sending.rs index 90657efa1..a78b4ab9d 100644 --- a/apps/maple-agent/app/src/ui/chat/sending.rs +++ b/apps/maple-agent/app/src/ui/chat/sending.rs @@ -13,6 +13,8 @@ pub(super) struct SendPreview { baseline_user_rows: HashSet, snapshot_projected: bool, phase: SendPhase, + /// When the preview was made: its bubble's entrance starts here. + shown: std::time::Instant, } struct PreviewAttachment { @@ -52,6 +54,7 @@ impl SendPreview { baseline_user_rows: HashSet::new(), snapshot_projected: false, phase: SendPhase::Admitting, + shown: motion::now(), } } @@ -297,11 +300,32 @@ impl ChatScreen { .retain(|_, preview| !preview.settled()); } - /// The message being sent, under the transcript. It rises in when the - /// send starts; the real row that replaces it does not enter again. - pub(super) fn render_send_preview(&self) -> Option> { + /// The message being sent, under the transcript. Its bubble rises in + /// from the moment it shows; the status line under it fades in once, + /// in the slot the waiting dots use, so the real row that takes over + /// lands where the bubble was. + pub(super) fn render_send_preview( + &self, + window: &mut Window, + cx: &gpui::App, + ) -> Option> { + self.row_motion.preview_body.set(None); let preview = self.current_send_preview()?; + let body_visible = preview.body_visible(); + let entrance = motion::progress( + preview.shown, + motion::ENTER, + motion::now(), + cx.reduce_motion(), + ); + if body_visible && entrance < 1. { + window.request_animation_frame(); + } + let body_bounds = self.row_motion.preview_body.clone(); let row = div() + .on_children_prepainted(move |bounds, _, _| { + body_bounds.set(bounds.first().copied().filter(|_| body_visible)); + }) .id("pending-send") .debug_selector(|| "pending-send".to_string()) .role(gpui::Role::Status) @@ -315,62 +339,66 @@ impl ChatScreen { .max_w(CONTENT_WIDTH) .mx_auto() .px_6() - .pb_2() .flex() .flex_col() .items_end() - .gap_1() - .when(preview.body_visible(), |row| { - row.child( - crate::ui::typography::chat_reading(div()) - .id("pending-send-body") - .debug_selector(|| "pending-send-body".to_string()) - .max_w(gpui::relative(0.75)) - .max_h(px(180.)) - .overflow_y_scroll() - // A long message scrolls inside its bubble; the - // pane must not scroll the transcript as well. - .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) - .px_4() - .py_2() - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::bg_user_bubble())) - .border_1() - .border_color(gpui::rgb(theme::user_bubble_border())) - .text_color(gpui::rgb(theme::text_primary())) - .when(!preview.attachments.is_empty(), |body| { - body.child(div().flex().flex_wrap().gap_2().mb_1().children( - preview.attachments.iter().map(|attachment| { - match &attachment.image { - Some(image) => div().child( - gpui::img(gpui::ImageSource::Image(image.clone())) - .size_16() - .rounded(theme::RADIUS_SM), - ), - None => div() - .flex() - .flex_col() - .text_xs() - .gap_1() - .child(attachment.name.clone()) - .child( - div() - .text_color(gpui::rgb(theme::text_muted())) - .child(attachments::cached_attachment_detail( - &attachment.mime, - attachment.size, - &self.messages, - )), + .gap(transcript::PREVIEW_GAP) + .when(body_visible, |row| { + let body = crate::ui::typography::chat_reading(div()) + .id("pending-send-body") + .debug_selector(|| "pending-send-body".to_string()) + .max_w(gpui::relative(0.75)) + .max_h(px(180.)) + .overflow_y_scroll() + // A long message scrolls inside its bubble; the + // pane must not scroll the transcript as well. + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .px_4() + .py_2() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_user_bubble())) + .border_1() + .border_color(gpui::rgb(theme::user_bubble_border())) + .text_color(gpui::rgb(theme::text_primary())) + .when(!preview.attachments.is_empty(), |body| { + body.child(div().flex().flex_wrap().gap_2().mb_1().children( + preview.attachments.iter().map(|attachment| { + match &attachment.image { + Some(image) => div().child( + gpui::img(gpui::ImageSource::Image(image.clone())) + .size_16() + .rounded(theme::RADIUS_SM), + ), + None => div() + .flex() + .flex_col() + .text_xs() + .gap_1() + .child(attachment.name.clone()) + .child( + div().text_color(gpui::rgb(theme::text_muted())).child( + attachments::cached_attachment_detail( + &attachment.mime, + attachment.size, + &self.messages, + ), ), - } - }), - )) - }) - .child(preview.text.clone()), - ) + ), + } + }), + )) + }) + .child(preview.text.clone()); + row.child(if entrance < 1. { + motion::arrive(body, entrance).into_any_element() + } else { + body.into_any_element() + }) }) - .child( + .child(motion::fade_in( div() + .debug_selector(|| "pending-send-status".to_string()) + .h(transcript::WAITING_ROW_HEIGHT) .flex() .items_center() .gap_1() @@ -392,13 +420,9 @@ impl ChatScreen { } }), ), - ); - Some(motion::slide_in( - row, - "pending-send-enter", - motion::ENTER, - gpui::point(px(0.), motion::RISE), - )) + "pending-send-status-reveal", + )); + Some(row) } } diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index f555cd7e0..1c2a60a69 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -2,11 +2,13 @@ //! shared `TranscriptCtx` caches; nothing here parses or derives on a //! frame. +use std::cell::{Cell, RefCell}; use std::collections::HashMap; +use std::rc::Rc; use std::sync::Arc; use std::time::Instant; -use gpui::{Div, Entity, IntoElement, SharedString, Window, div, prelude::*, px}; +use gpui::{Bounds, Div, Entity, IntoElement, Pixels, SharedString, Window, div, prelude::*, px}; use maple_agent::agent::{ AgentPermissionReason, AgentTimelineItem, EXTERNAL_AGENT_ACTIVITY_KEY, ExternalAgentActivity, ExternalAgentRef, USAGE_LIMIT_MESSAGE, @@ -67,12 +69,12 @@ impl ChatScreen { return div().into_any_element(); }; let chat = chat_entity.read(cx); - // A row that arrived live rises in, timed from view state so + // A row that arrived live enters, timed from view state so // scrolling it away and back never replays the entrance. - let arrival = chat + let entrance = chat .timeline .get(ix) - .map_or(1., |item| chat.row_motion.arrival(&item.id, now, reduced)); + .and_then(|item| chat.row_motion.entrance(&item.id, now, reduced)); let row = match chat.timeline.get(ix) { Some(item) => { let render_ctx = RenderCtx { @@ -170,12 +172,29 @@ impl ChatScreen { })); } }); - // Paint offset and opacity only: the row measures the same - // throughout its entrance. - if arrival < 1. { - motion::arrive(row, arrival).into_any_element() - } else { - row.into_any_element() + match entrance { + None => row.into_any_element(), + // Scrolled up: paint offset and opacity only, so nothing + // on screen moves. + Some(Entrance { + progress, + grow: None, + .. + }) => motion::arrive(row, progress).into_any_element(), + // At the tail: the row opens to its height and the rows + // above glide up with it. + Some(Entrance { + progress, + grow: Some(grow), + natural, + }) => grow_in( + row, + progress, + grow, + natural, + chat.row_motion.heights.clone(), + chat.timeline[ix].id.clone(), + ), } }) .size_full(); @@ -372,6 +391,16 @@ impl ChatScreen { } } +/// Height of the row under the transcript that shows the waiting dots or, +/// while a message is being sent, its status line. Both take the same +/// slot so one replacing the other moves nothing. +pub(super) const WAITING_ROW_HEIGHT: Pixels = px(36.); + +/// Space between the message being sent and its status line: the +/// transcript's bottom padding, so the real row lands where the preview +/// was. +pub(super) const PREVIEW_GAP: Pixels = px(16.); + /// Entrances in the transcript, timed from view state. A `gpui::list` /// drops a row's element state when it scrolls away, so an entrance kept /// there would replay; these start times outlive the elements, and a @@ -379,31 +408,94 @@ impl ChatScreen { #[derive(Default)] pub(super) struct RowMotion { /// Rows that arrived live in the task on screen, by item id. - arrived: HashMap, + arrived: HashMap, + /// Natural heights of rows growing into place, measured as they + /// paint; a row's first frame has none and opens from its start. + pub(super) heights: Rc>>, /// Rows whose details the user just opened, by item id. disclosed: HashMap, /// When another task's transcript landed. switched: Option, - /// The user row that takes over from the send preview already on - /// screen; it does not enter a second time. - handed_off: Option, + /// The user row that takes over from the send preview on screen, and + /// where the preview's bubble was. + handed_off: Option<(String, Option>)>, + /// Where the send preview's bubble painted last frame, if it showed. + pub(super) preview_body: Rc>>>, /// Rows apply without an entrance (events replayed on reconnect). quiet: bool, + /// The slot under the transcript (waiting dots or the sent message's + /// status) showed last frame. + pub(super) slot_on_screen: bool, +} + +#[derive(Clone, Copy)] +struct Arrival { + at: Instant, + grow: Option, +} + +/// How a row that arrived at the followed tail opens. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Grow { + /// Height it opens from: 0, or the space a vanished preview or + /// waiting row left, so the rows above stay put on the first frame. + pub(super) from: Pixels, + /// Window y its content starts at (the preview's bubble), easing to + /// its own place; such a row is already visible and does not fade. + pub(super) glide_from: Option, +} + +/// A row's entrance at one moment. +pub(super) struct Entrance { + pub(super) progress: f32, + pub(super) grow: Option, + pub(super) natural: Option, } impl RowMotion { /// A row was added live; history, paging and snapshots never call this. - pub(super) fn arrive(&mut self, id: &str, now: Instant) { + /// `at_tail`: the list follows its tail, so the row grows into place; + /// otherwise it only fades and rises. + pub(super) fn arrive(&mut self, id: &str, now: Instant, at_tail: bool) { let handed_off = self.handed_off.take(); - if self.quiet || handed_off.as_deref() == Some(id) { + if self.quiet { return; } - self.arrived.insert(id.to_owned(), now); + let grow = match handed_off { + // The preview's bubble becomes this row: it opens from the + // space the bubble left and glides from where it was. + Some((handed, body)) if handed == id => { + let Some(body) = body.filter(|_| at_tail) else { + return; + }; + Some(Grow { + from: body.size.height + PREVIEW_GAP, + glide_from: Some(body.origin.y), + }) + } + _ => at_tail.then_some(Grow { + from: px(0.), + glide_from: None, + }), + }; + self.arrived + .insert(id.to_owned(), Arrival { at: now, grow }); + } + + /// The row `id` also takes the place of `height` that left the space + /// under the transcript (the waiting row or the preview's status). + pub(super) fn open_from_more(&mut self, id: &str, height: Pixels) { + if let Some(Arrival { + grow: Some(grow), .. + }) = self.arrived.get_mut(id) + { + grow.from += height; + } } /// The next row with `id` replaces the send preview on screen. pub(super) fn hand_off(&mut self, id: String) { - self.handed_off = Some(id); + self.handed_off = Some((id, self.preview_body.get())); } /// Apply rows without an entrance until called again with `false`. @@ -413,10 +505,15 @@ impl RowMotion { /// A live row took its persisted id; its entrance carries on. pub(super) fn rekey(&mut self, old: &str, new: &str) { - for started in [&mut self.arrived, &mut self.disclosed] { - if let Some(at) = started.remove(old) { - started.insert(new.to_owned(), at); - } + if let Some(arrival) = self.arrived.remove(old) { + self.arrived.insert(new.to_owned(), arrival); + } + if let Some(at) = self.disclosed.remove(old) { + self.disclosed.insert(new.to_owned(), at); + } + let mut heights = self.heights.borrow_mut(); + if let Some(height) = heights.remove(old) { + heights.insert(new.to_owned(), height); } } @@ -437,16 +534,30 @@ impl RowMotion { /// A new transcript replaced every row; none of them is arriving. pub(super) fn clear_rows(&mut self) { self.arrived.clear(); + self.heights.borrow_mut().clear(); self.disclosed.clear(); self.handed_off = None; } - /// Progress of the row's entrance; 1 for a row that did not arrive live - /// or has settled. + /// The row's entrance, or `None` for a row that did not arrive live or + /// has settled. + pub(super) fn entrance(&self, id: &str, now: Instant, reduced: bool) -> Option { + let arrival = self.arrived.get(id)?; + let progress = motion::progress(arrival.at, motion::ENTER, now, reduced); + (progress < 1.).then(|| Entrance { + progress, + grow: arrival.grow, + natural: arrival + .grow + .and_then(|_| self.heights.borrow().get(id).copied()), + }) + } + + /// Progress of the row's entrance; 1 when it has none. + #[cfg(test)] pub(super) fn arrival(&self, id: &str, now: Instant, reduced: bool) -> f32 { - self.arrived - .get(id) - .map_or(1., |at| motion::progress(*at, motion::ENTER, now, reduced)) + self.entrance(id, now, reduced) + .map_or(1., |entrance| entrance.progress) } /// Progress of the fade of details the user just opened. @@ -467,18 +578,138 @@ impl RowMotion { pub(super) fn settle(&mut self, now: Instant, reduced: bool) -> bool { if reduced { self.arrived.clear(); + self.heights.borrow_mut().clear(); self.disclosed.clear(); self.switched = None; return false; } let running = |at: &Instant, duration| now.saturating_duration_since(*at) < duration; - self.arrived.retain(|_, at| running(at, motion::ENTER)); + self.arrived + .retain(|_, arrival| running(&arrival.at, motion::ENTER)); + self.heights + .borrow_mut() + .retain(|id, _| self.arrived.contains_key(id)); self.disclosed.retain(|_, at| running(at, motion::REVEAL)); self.switched = self.switched.filter(|at| running(at, motion::REVEAL)); !self.arrived.is_empty() || !self.disclosed.is_empty() || self.switched.is_some() } } +/// A row opening at the tail: its box eases from `grow.from` to its +/// natural height, clipped, so the rows above glide up rather than jump. +/// Its natural height is measured from the unclipped content each frame, +/// so a reply streaming in meanwhile still ends at its true height. +fn grow_in( + row: Div, + progress: f32, + grow: Grow, + natural: Option, + heights: Rc>>, + id: String, +) -> gpui::AnyElement { + let eased = motion::ease_out_cubic(progress); + let height = natural.map_or(grow.from, |natural| { + grow.from + (natural - grow.from) * eased + }); + let content = div().absolute().top_0().left_0().right_0().child(row); + let content = match grow.glide_from { + Some(from_y) => GlideFrom { + element: Some(content.into_any_element()), + from_y, + progress, + } + .into_any_element(), + None => content + .opacity(motion::ease_out_quint(progress)) + .into_any_element(), + }; + div() + .relative() + .h(height) + .overflow_hidden() + .child(content) + .on_children_prepainted(move |bounds, _, _| { + if let Some(content) = bounds.first() { + heights.borrow_mut().insert(id.clone(), content.size.height); + } + }) + .into_any_element() +} + +/// Paints its child starting at window y `from_y` and easing to where +/// layout put it. The offset is worked out from its own bounds as it +/// prepaints, so the first frame is already in the right place. +struct GlideFrom { + element: Option, + from_y: Pixels, + progress: f32, +} + +impl IntoElement for GlideFrom { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl gpui::Element for GlideFrom { + type RequestLayoutState = gpui::AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut gpui::App, + ) -> (gpui::LayoutId, gpui::AnyElement) { + let mut element = self + .element + .take() + .expect("a gliding element is laid out once"); + (element.request_layout(window, cx), element) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + element: &mut gpui::AnyElement, + window: &mut Window, + cx: &mut gpui::App, + ) { + let remaining = 1. - motion::ease_out_cubic(self.progress); + let offset = gpui::point( + px(0.), + ((self.from_y - bounds.origin.y) * remaining).round(), + ); + window.with_element_offset(offset, |window| element.prepaint(window, cx)); + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + _bounds: Bounds, + element: &mut gpui::AnyElement, + _: &mut (), + window: &mut Window, + cx: &mut gpui::App, + ) { + element.paint(window, cx); + } +} + pub(super) fn render_timeline_item( item: &AgentTimelineItem, revision: u64, @@ -947,9 +1178,11 @@ fn tool_status_style(status: Option<&str>) -> (SharedString, u32) { } } -/// A tool card's spinner and status label. A call that finishes on screen -/// cross-fades from its running state over [`motion::TOGGLE`]; a card that -/// mounts finished (history, a row scrolled back) shows its state at once. +/// A tool card's spinner and status label. When the call finishes on +/// screen the running state goes at once and the outcome fades in over +/// [`motion::TOGGLE`]: two labels drawn over each other would read as +/// one garbled word. A card that mounts finished (history, a row scrolled +/// back) shows its state at once. fn tool_status(item_id: &str, running: bool, label: SharedString, color: u32) -> motion::Animated { let spinner_id = SharedString::from(format!("tool-spinner-{item_id}")); motion::animated( @@ -958,42 +1191,24 @@ fn tool_status(item_id: &str, running: bool, label: SharedString, color: u32) -> motion::TOGGLE, motion::ease_out_cubic, move |done| { - let state = |spinning: bool, label: SharedString, color: u32| { - div() - .flex() - .items_center() - .gap_2() - .when(spinning, |state| { - state.child(spinner_with_id( - spinner_id.clone(), - px(12.), - theme::accent(), - )) - }) - .child(div().text_xs().text_color(gpui::rgb(color)).child(label)) - }; - if running { - return state(true, label.clone(), color) - .opacity(1. - done) - .into_any_element(); - } - // The running state fades out over the outcome, aligned to the - // same right edge, then leaves. div() - .relative() - .child(state(false, label.clone(), color).opacity(done)) - .when(done < 1., |slot| { - let (running_label, running_color) = tool_status_style(None); - slot.child( - state(true, running_label, running_color) - .absolute() - .top_0() - .bottom_0() - .right_0() - .whitespace_nowrap() - .opacity(1. - done), - ) + .flex() + .items_center() + .gap_2() + .opacity(if running { 1. - done } else { done }) + .when(running, |state| { + state.child(spinner_with_id( + spinner_id.clone(), + px(12.), + theme::accent(), + )) }) + .child( + div() + .text_xs() + .text_color(gpui::rgb(color)) + .child(label.clone()), + ) .into_any_element() }, ) @@ -2269,7 +2484,8 @@ pub(super) fn render_waiting_indicator() -> impl IntoElement { .items_center() .gap_2() .px_4() - .py_2() + // The send preview's status line takes the same slot. + .h(WAITING_ROW_HEIGHT) .child(motion::ticker("waiting-dots", CYCLE, |phase| { let mut row = div().flex().items_center().gap_1p5(); for index in 0..3 { @@ -2297,7 +2513,9 @@ pub(super) fn render_waiting_indicator() -> impl IntoElement { mod motion_tests { use std::time::{Duration, Instant}; - use gpui::{Context, TestAppContext}; + use gpui::{Bounds, Context, Pixels, TestAppContext}; + + use super::WAITING_ROW_HEIGHT; use crate::ui::chat::*; use crate::ui::motion; @@ -2378,44 +2596,104 @@ mod motion_tests { chat.update(cx, |chat, _| chat.row_motion.settle(now, false)) } - #[gpui::test] - fn a_live_row_rises_in_once_and_keeps_its_height(cx: &mut TestAppContext) { - struct TranscriptHost { - chat: Entity, - } - impl Render for TranscriptHost { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div().w(px(1200.)).h(px(800.)).flex().flex_col().child( - self.chat - .update(cx, |chat, cx| chat.render_transcript(window, cx)), - ) - } + /// The chat pane's middle: the transcript and the slot under it. + struct PaneHost { + chat: Entity, + } + + impl Render for PaneHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let pane = self.chat.update(cx, |chat, cx| { + let transcript = chat.render_transcript(window, cx); + let under = chat.render_under_transcript(window, cx); + div() + .size_full() + .flex() + .flex_col() + .child(transcript) + .children(under.into_iter().flatten()) + }); + div().w(px(1200.)).h(px(800.)).flex().flex_col().child(pane) } - let clock = Clock::start(); + } + + fn pane( + cx: &mut TestAppContext, + rows: usize, + ) -> (Entity, &mut gpui::VisualTestContext) { let chat = screen(cx); chat.update(cx, |chat, _| { chat.replace_timeline( - (0..30) + (0..rows) .map(|index| item(&format!("h{index}"), "message", Some("Earlier row"))) .collect(), ); }); - let (_host, cx) = cx.add_window_view(|_, _| TranscriptHost { chat: chat.clone() }); + let (_host, cx) = cx.add_window_view(|_, _| PaneHost { chat: chat.clone() }); cx.simulate_resize(gpui::size(px(1200.), px(800.))); cx.run_until_parked(); - assert!(!in_flight(&chat, clock.0, cx), "history arrives at rest"); + (chat, cx) + } - let redraw = |cx: &mut gpui::VisualTestContext| { - chat.update(cx, |_, cx| cx.notify()); - cx.run_until_parked(); - }; + fn redraw(chat: &Entity, cx: &mut gpui::VisualTestContext) { + chat.update(cx, |_, cx| cx.notify()); + cx.run_until_parked(); + } + + fn bounds(cx: &mut gpui::VisualTestContext, selector: &'static str) -> Bounds { + cx.debug_bounds(selector) + .unwrap_or_else(|| panic!("{selector} painted")) + } + + /// Samples each selector's top at 60 fps through one entrance that + /// began at `start`. + fn tops_through_entrance( + chat: &Entity, + clock: &Clock, + start: Duration, + selectors: &[&'static str], + cx: &mut gpui::VisualTestContext, + ) -> Vec> { + let mut tops = vec![Vec::new(); selectors.len()]; + let mut at = Duration::ZERO; + while at <= motion::ENTER + Duration::from_millis(16) { + clock.at(start + at); + redraw(chat, cx); + for (selector, tops) in selectors.iter().zip(&mut tops) { + tops.push(bounds(cx, selector).origin.y); + } + at += Duration::from_millis(16); + } + tops + } + + /// Every frame-to-frame move stays within what the easing allows: no + /// single-frame jump. + fn assert_glides(tops: &[Pixels], label: &str) { + let total = (tops[0] - tops[tops.len() - 1]).abs(); + // ease_out_cubic is steepest at its start: 3x the mean speed, or + // 16 ms * 3 / 180 ms of the distance per frame. + let limit = total * 0.3 + px(1.); + for pair in tops.windows(2) { + assert!( + (pair[1] - pair[0]).abs() <= limit, + "{label} jumped {:?} in one frame (limit {limit:?}): {tops:?}", + pair[1] - pair[0] + ); + } + } + + #[gpui::test] + fn a_row_arriving_while_scrolled_up_rises_without_a_layout_change(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 3); + assert!(!in_flight(&chat, clock.0, cx), "history arrives at rest"); chat.update(cx, |chat, cx| { + chat.list_state.pause_following_tail(); chat.apply_incoming_item("s1", item("live", "message", Some("Hello")), cx); }); - redraw(cx); - let entering = cx - .debug_bounds("timeline-row-live") - .expect("live row painted"); + redraw(&chat, cx); + let entering = bounds(cx, "timeline-row-live"); let midway = clock.at(motion::ENTER / 2); let progress = chat.update(cx, |chat, _| chat.row_motion.arrival("live", midway, false)); @@ -2426,14 +2704,9 @@ mod motion_tests { assert!(in_flight(&chat, midway, cx), "frames continue mid-entrance"); let done = clock.at(motion::ENTER); - redraw(cx); - let settled = cx - .debug_bounds("timeline-row-live") - .expect("live row painted"); - assert_eq!( - entering.size, settled.size, - "the entrance never changes layout" - ); + redraw(&chat, cx); + let settled = bounds(cx, "timeline-row-live"); + assert_eq!(entering.size, settled.size, "the rise never changes layout"); assert_eq!(entering.origin.y - settled.origin.y, motion::RISE); assert!(!in_flight(&chat, done, cx), "frames stop once it settles"); @@ -2444,13 +2717,173 @@ mod motion_tests { offset_in_item: px(0.), }); }); - redraw(cx); + redraw(&chat, cx); chat.update(cx, |chat, _| chat.list_state.scroll_to_end()); - redraw(cx); + redraw(&chat, cx); assert_eq!(cx.debug_bounds("timeline-row-live"), Some(settled)); assert!(!in_flight(&chat, done, cx)); } + #[gpui::test] + fn a_tail_row_grows_into_place_and_older_rows_glide(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 30); + let before = bounds(cx, "timeline-row-h29"); + chat.update(cx, |chat, cx| { + chat.apply_incoming_item( + "s1", + item("live", "message", Some("Hello\n\nA second paragraph")), + cx, + ); + }); + let tops = tops_through_entrance(&chat, &clock, Duration::ZERO, &["timeline-row-h29"], cx) + .remove(0); + assert!( + (tops[0] - before.origin.y).abs() <= px(1.), + "older rows hold still on the arrival frame: {before:?} then {tops:?}" + ); + assert!( + tops[tops.len() - 1] < before.origin.y, + "older rows made room" + ); + assert!( + tops.windows(2).all(|pair| pair[1] <= pair[0]), + "older rows only rise: {tops:?}" + ); + assert_glides(&tops, "an older row"); + assert!(!in_flight(&chat, clock.at(motion::ENTER), cx)); + + // The settled layout is the one without motion. + let older = bounds(cx, "timeline-row-h29"); + let live = bounds(cx, "timeline-row-live"); + chat.update(cx, |chat, _| { + let rows = chat.timeline.clone(); + chat.replace_timeline(rows); + }); + redraw(&chat, cx); + assert_eq!(bounds(cx, "timeline-row-h29"), older); + assert_eq!(bounds(cx, "timeline-row-live"), live); + } + + #[gpui::test] + fn a_reply_streaming_while_it_opens_ends_at_its_true_height(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 30); + chat.update(cx, |chat, cx| { + chat.apply_incoming_item("s1", item("reply", "message", Some("Answer:")), cx); + }); + redraw(&chat, cx); + clock.at(motion::ENTER / 3); + redraw(&chat, cx); + chat.update(cx, |chat, cx| { + chat.apply_incoming_item( + "s1", + AgentTimelineItem { + merge: "append".to_string(), + text: Some("\n\nMore lines\n\nAnd more".to_string()), + ..item("reply", "message", None) + }, + cx, + ); + }); + for step in [motion::ENTER / 2, motion::ENTER * 3 / 4, motion::ENTER] { + clock.at(step); + redraw(&chat, cx); + } + let older = bounds(cx, "timeline-row-h29"); + let reply = bounds(cx, "timeline-row-reply"); + chat.update(cx, |chat, _| { + let rows = chat.timeline.clone(); + chat.replace_timeline(rows); + }); + redraw(&chat, cx); + assert_eq!(bounds(cx, "timeline-row-h29"), older); + assert_eq!(bounds(cx, "timeline-row-reply"), reply); + } + + #[gpui::test] + fn the_sent_bubble_rises_in_and_its_status_does_not(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 3); + chat.update(cx, |chat, _| { + let preview = sending::SendPreview::new(Some("s1".into()), "hello", &[]) + .capture_timeline(&chat.timeline); + chat.sending_previews.insert("send-1".into(), preview); + }); + redraw(&chat, cx); + let entering = bounds(cx, "pending-send-body"); + let status = bounds(cx, "pending-send-status"); + clock.at(motion::ENTER); + redraw(&chat, cx); + let settled = bounds(cx, "pending-send-body"); + assert_eq!(entering.origin.y - settled.origin.y, motion::RISE); + assert_eq!(entering.size, settled.size); + assert_eq!( + bounds(cx, "pending-send-status"), + status, + "the status line has no offset of its own or its parent's" + ); + assert_eq!(status.size.height, WAITING_ROW_HEIGHT); + } + + #[gpui::test] + fn the_real_row_takes_over_from_the_bubble_without_a_jump(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 30); + chat.update(cx, |chat, _| { + let preview = sending::SendPreview::new(Some("s1".into()), "hello", &[]) + .capture_timeline(&chat.timeline); + chat.sending_previews.insert("send-1".into(), preview); + }); + redraw(&chat, cx); + let start = motion::ENTER * 2; + clock.at(start); + redraw(&chat, cx); + let bubble = bounds(cx, "pending-send-body"); + let older = bounds(cx, "timeline-row-h29"); + let status = bounds(cx, "pending-send-status"); + + chat.update(cx, |chat, cx| { + chat.handle_service_event( + AgentServiceEvent::TimelineItem { + session_id: "s1".into(), + run_id: Some("run-1".into()), + item: user_item("mine", "hello"), + }, + cx, + ); + }); + let tops = tops_through_entrance( + &chat, + &clock, + start, + &[ + "timeline-row-mine", + "timeline-row-h29", + "pending-send-status", + ], + cx, + ); + assert!(cx.debug_bounds("pending-send-body").is_none(), "handed off"); + assert!( + (tops[0][0] - bubble.origin.y).abs() <= px(1.), + "the row starts where the bubble was: {bubble:?} then {:?}", + tops[0] + ); + assert!( + (tops[1][0] - older.origin.y).abs() <= px(1.), + "older rows hold still on the handoff frame: {older:?} then {:?}", + tops[1] + ); + assert!( + tops[2].iter().all(|top| *top == status.origin.y), + "the status line keeps its slot: {:?}", + tops[2] + ); + assert_glides(&tops[0], "the handed-off row"); + assert_glides(&tops[1], "an older row"); + } + #[gpui::test] fn history_task_switches_paging_and_reconnects_do_not_animate(cx: &mut TestAppContext) { let clock = Clock::start(); From d068a5608bfef6875c86538bed067f25c7cd3b92 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:32:13 -0800 Subject: [PATCH 18/29] Agent: describe how arriving rows open in the motion doc --- apps/maple-agent/docs/motion.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index 2b6b517ec..1838e8e30 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -20,9 +20,10 @@ between the user and the next action. This page records the shared decisions; not wait for it. Reversible state changes run the same curve both ways. - **Opacity first, small distances.** gpui cannot scale or transform a `div`, so content enters with opacity and at most a 6 px rise, drawn as a - relative offset that does not change layout. Only SVG icons scale or - rotate. Height animates only where a box truly changes size (text fields, - the expanded composer, the sidebar). + paint offset that does not change layout. Only SVG icons scale or rotate. + Height animates only where a box truly changes size: text fields, the + expanded composer, the sidebar, and a row arriving at the end of the + thread, which opens so older rows glide up rather than jump. - **High-frequency work stays still.** Streaming tokens, typed characters, keyboard navigation inside menus and lists, and history that loads or scrolls into view do not animate. Rows animate once, when they arrive @@ -59,9 +60,13 @@ shared 15 fps `ticker` clock and pause in background windows. | Dialogs and confirmations | scrim fades, card fades and rises, `ENTER` | immediate | | Screen change (Chat ↔ Settings) and Settings sections | new content fades in, `REVEAL` | immediate | | Switching tasks | transcript fades in when its snapshot lands, `REVEAL` | immediate | -| Live transcript rows (sent message, reply, tool card, error, stop notice) | fade and 6 px rise, `ENTER` | — | +| Live transcript rows at the end of the thread (sent message, reply, thinking, tool card, error, stop notice) | height grows from 0 and the row fades in, `ENTER`, so older rows glide up; scrolled up, a fade and 6 px rise | — | +| Sent message | the bubble rises in; the real row takes its exact place, and "Sending…" and the waiting dots share one slot under it | — | +| Tool status | spinner leaves at once, the outcome fades in, `TOGGLE` | — | +| Disclosed details (tool output, thinking) | fade in, `REVEAL` | immediate | | Streaming text | grows in place, no animation | — | -| Cards above the composer, queued messages, send preview | fade and rise, `ENTER` | immediate | +| Waiting dots | fade in, `REVEAL` | immediate | +| Notices, queued messages, cards above the composer | fade and rise, `ENTER` | immediate | | Files attached to the draft | fade and 6 px rise, `ENTER` | immediate | | Composer and multi-line fields | height eases to the new line count, `resize_duration` | same | | Composer expand / shrink | height eases, `PANEL`; maximize and minimize icons cross-fade and scale, `TOGGLE` | same, reversed | @@ -69,7 +74,7 @@ shared 15 fps `ticker` clock and pause in background windows. | Switches and checkboxes | knob slides, fill cross-fades, `TOGGLE` | same, reversed | | Disclosure chevrons | rotate 90°, `TOGGLE` | same, reversed | | Sidebar collapse | width eases, `PANEL` | same, reversed | -| Task status in the sidebar | indicators cross-fade, `TOGGLE` | same | +| Task status in the sidebar, project folders | indicators and folder icons cross-fade, `TOGGLE` | same | Loading spinners for work that usually finishes quickly (opening a task) appear only after 300 ms, so fast loads never flash a spinner. From a5c0a0ebb49522ccb26e3d397d573e8aa0f7a278 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:03:21 -0800 Subject: [PATCH 19/29] Agent: the composer glides between the empty state and the thread When the empty state gives way to a conversation (the first message, opening a task) or back (New Task), the composer region glides from where it was last drawn to its new place over 200 ms instead of jumping. glide_from moves into motion.rs so the transcript's hand-off and the composer share one paint-offset primitive. Menus the keyboard opens appear at once; pointer openings keep their entrance. --- apps/maple-agent/app/src/ui/chat/composer.rs | 116 ++++++++++++++++++ apps/maple-agent/app/src/ui/chat/mod.rs | 27 +++- .../maple-agent/app/src/ui/chat/transcript.rs | 81 +----------- apps/maple-agent/app/src/ui/motion.rs | 84 +++++++++++++ apps/maple-agent/app/src/ui/popup.rs | 46 +++++-- apps/maple-agent/docs/motion.md | 6 +- 6 files changed, 265 insertions(+), 95 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 355fab789..cf7ada241 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -95,6 +95,9 @@ fn mode_note(mode: AgentPermissionMode) -> &'static str { pub(super) struct ComposerFrame { /// The composer region: cards, composer box, palette, padding. pub(super) region: Option, + /// The region's top in the window, so the composer can glide from + /// there when the empty state gives way to a conversation, or back. + pub(super) top: Option, /// The height the region takes when expanded. pub(super) room: Option, /// The empty state's title and privacy line at rest, which fold away @@ -125,6 +128,18 @@ pub(super) fn measure( .inset_0() } +/// The composer region, painted gliding from window y `from` toward its +/// place while `glide` runs (see [`ChatScreen::composer_glide_frame`]). +pub(super) fn glide( + region: impl IntoElement, + glide: Option<(gpui::Pixels, f32)>, +) -> gpui::AnyElement { + match glide { + Some((from, progress)) => motion::glide_from(region, from, progress).into_any_element(), + None => region.into_any_element(), + } +} + impl ChatScreen { pub(super) fn render_header( &self, @@ -1294,6 +1309,40 @@ impl ChatScreen { None } + /// When the empty state gives way to a conversation (the first message, + /// or opening a task) or back (New Task), the composer glides from where + /// it was last drawn to its new place over [`motion::PANEL`]. Returns + /// the window y it starts from and how far along it is; `None` at rest, + /// on the first frame, and under reduced motion. + pub(super) fn composer_glide_frame( + &mut self, + empty: bool, + window: &mut Window, + cx: &mut Context, + ) -> Option<(gpui::Pixels, f32)> { + let now = motion::now(); + let reduced = cx.reduce_motion(); + if self.composer_was_empty.is_some_and(|was| was != empty) { + self.composer_glide = self + .composer_frame + .get() + .top + .filter(|_| !reduced) + .map(|from| (from, now)); + } + self.composer_was_empty = Some(empty); + let glide = self.composer_glide.and_then(|(from, started)| { + let progress = motion::progress(started, motion::PANEL, now, reduced); + (progress < 1.).then_some((from, progress)) + }); + if glide.is_some() { + window.request_animation_frame(); + } else { + self.composer_glide = None; + } + glide + } + /// The composer box lays out expanded: at rest there, or on the way. pub(super) fn composer_fills(&self) -> bool { self.composer_expanded || self.composer_resize.is_some() @@ -2028,4 +2077,71 @@ mod resize_tests { assert_eq!(cx.debug_bounds("composer-box"), Some(rest)); motion::set_test_now(None); } + + /// The first message carries the composer from the empty state down to + /// the foot of the conversation: it starts where it was drawn, moves + /// only down, lands exactly at rest, and then asks for no frames. New + /// Task under reduced motion puts it straight back. + #[gpui::test] + fn leaving_the_empty_state_glides_the_composer_down(cx: &mut TestAppContext) { + let start = Instant::now(); + motion::set_test_now(Some(start)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (chat, cx) = chat_window(cx, 0); + let hero = cx.debug_bounds("composer-box").expect("composer"); + + let message = |chat: &mut ChatScreen| { + chat.replace_timeline(vec![AgentTimelineItem { + label: None, + persisted: None, + live: None, + truncated: None, + revision: 0, + id: "m0".to_string(), + item_type: "message".to_string(), + role: None, + title: None, + text: Some("Hello".to_string()), + status: None, + input: None, + output: None, + created_ms: 0, + merge: "replace".to_string(), + }]); + }; + chat.update(cx, |chat, cx| { + message(chat); + cx.notify(); + }); + cx.run_until_parked(); + let first = cx.debug_bounds("composer-box").expect("composer"); + assert!( + (first.top() - hero.top()).abs() < px(1.), + "starts where it was drawn: {hero:?} {first:?}" + ); + let mut last = first.top(); + for step in 1..=12 { + frame_at(start + motion::PANEL * step / 12, cx); + let top = cx.debug_bounds("composer-box").expect("composer").top(); + assert!(top >= last, "only moves down: {last:?} then {top:?}"); + last = top; + } + frame_at(start + motion::PANEL * 2, cx); + let rest = cx.debug_bounds("composer-box").expect("composer"); + assert!(rest.top() > hero.top() + px(100.), "{hero:?} {rest:?}"); + assert_eq!(cx.update(|window, app| window.simulate_next_frame(app)), 0); + + cx.update(|_, cx| cx.set_reduce_motion(true)); + chat.update(cx, |chat, cx| { + chat.replace_timeline(Vec::new()); + cx.notify(); + }); + cx.run_until_parked(); + let back = cx.debug_bounds("composer-box").expect("composer"); + assert!( + (back.top() - hero.top()).abs() < px(1.), + "reduced motion lands at once: {hero:?} {back:?}" + ); + motion::set_test_now(None); + } } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 626f82796..5ca77486e 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -655,6 +655,12 @@ pub struct ChatScreen { composer_resize: Option, /// Heights that transition eases between, measured last frame. composer_frame: std::rc::Rc>, + /// Whether the last frame drew the empty state; `None` before the + /// first frame, which never glides. + composer_was_empty: Option, + /// The window y the composer region glides from, and since when, after + /// the empty state gives way to a conversation or back. + composer_glide: Option<(gpui::Pixels, std::time::Instant)>, /// Send (0) or Stop (1) on the composer's main control. send_stop: motion::Tween, /// The send fill, disabled (0) or ready (1). @@ -1527,6 +1533,8 @@ impl ChatScreen { composer_expanded: false, composer_resize: None, composer_frame: std::rc::Rc::default(), + composer_was_empty: None, + composer_glide: None, send_stop: motion::Tween::new(0.), send_ready: motion::Tween::new(0.), web_enabled: true, @@ -7243,13 +7251,14 @@ impl Render for ChatScreen { .clone() .map(|status| self.render_trust_prompt(&status, cx)); let empty = self.shows_empty_state(); + let composer_glide = self.composer_glide_frame(empty, window, cx); let sidebar_expansion = self.sidebar_expansion(window, cx); let rise = gpui::point(px(0.), motion::RISE); let composer_resize = self.composer_resize_frame(window, cx); let main = if empty { // No slot under a transcript here; rows opening from it start at 0. self.row_motion.slot_on_screen = false; - self.render_empty_state(composer_resize, window, cx) + self.render_empty_state(composer_resize, composer_glide, window, cx) } else { div() .flex() @@ -7279,7 +7288,7 @@ impl Render for ChatScreen { cx, )) }) - .child( + .child(composer::glide( div() .relative() .w_full() @@ -7322,9 +7331,11 @@ impl Render for ChatScreen { &self.composer_frame, |frame, bounds, _| { frame.region = Some(bounds.size.height); + frame.top = Some(bounds.origin.y); }, )), - ) + composer_glide, + )) // Expanded, the composer region takes 70% of this column. .child(composer::measure( &self.composer_frame, @@ -7950,10 +7961,12 @@ impl ChatScreen { /// and privacy note centered in the pane (mirrors EmptyAgentState). /// Expanded, the composer fills the pane; while it eases there and /// back (`composer_resize`), the heading and the note fold away and - /// fade with it so the column never jumps. + /// fade with it so the column never jumps. `composer_glide` carries + /// the composer up from the foot of a conversation. fn render_empty_state( &mut self, composer_resize: Option<(f32, gpui::Pixels)>, + composer_glide: Option<(gpui::Pixels, f32)>, window: &mut Window, cx: &mut Context, ) -> Div { @@ -8042,7 +8055,7 @@ impl ChatScreen { .items_center() .when(fills, |column| column.h_full().min_h_0().justify_center()) .children(title) - .child( + .child(composer::glide( div() .relative() .w_full() @@ -8061,9 +8074,11 @@ impl ChatScreen { &self.composer_frame, |frame, bounds, _| { frame.region = Some(bounds.size.height); + frame.top = Some(bounds.origin.y); }, )), - ) + composer_glide, + )) .when( self.awaiting_first_token && self.is_run_active(), |column| column.child(div().mt_6().child(render_waiting_indicator())), diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 1c2a60a69..9068dc06f 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -613,12 +613,7 @@ fn grow_in( }); let content = div().absolute().top_0().left_0().right_0().child(row); let content = match grow.glide_from { - Some(from_y) => GlideFrom { - element: Some(content.into_any_element()), - from_y, - progress, - } - .into_any_element(), + Some(from_y) => motion::glide_from(content, from_y, progress).into_any_element(), None => content .opacity(motion::ease_out_quint(progress)) .into_any_element(), @@ -636,80 +631,6 @@ fn grow_in( .into_any_element() } -/// Paints its child starting at window y `from_y` and easing to where -/// layout put it. The offset is worked out from its own bounds as it -/// prepaints, so the first frame is already in the right place. -struct GlideFrom { - element: Option, - from_y: Pixels, - progress: f32, -} - -impl IntoElement for GlideFrom { - type Element = Self; - - fn into_element(self) -> Self { - self - } -} - -impl gpui::Element for GlideFrom { - type RequestLayoutState = gpui::AnyElement; - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut gpui::App, - ) -> (gpui::LayoutId, gpui::AnyElement) { - let mut element = self - .element - .take() - .expect("a gliding element is laid out once"); - (element.request_layout(window, cx), element) - } - - fn prepaint( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - bounds: Bounds, - element: &mut gpui::AnyElement, - window: &mut Window, - cx: &mut gpui::App, - ) { - let remaining = 1. - motion::ease_out_cubic(self.progress); - let offset = gpui::point( - px(0.), - ((self.from_y - bounds.origin.y) * remaining).round(), - ); - window.with_element_offset(offset, |window| element.prepaint(window, cx)); - } - - fn paint( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _inspector_id: Option<&gpui::InspectorElementId>, - _bounds: Bounds, - element: &mut gpui::AnyElement, - _: &mut (), - window: &mut Window, - cx: &mut gpui::App, - ) { - element.paint(window, cx); - } -} - pub(super) fn render_timeline_item( item: &AgentTimelineItem, revision: u64, diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 8c01471d0..168dc3c50 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -479,6 +479,90 @@ impl Element for Shifted { } } +/// Paint an element starting at window y `from_y` and easing (ease-out +/// cubic, by `progress`) to where layout put it: a layout-free move from +/// where something was drawn last frame to its new place. The offset is +/// worked out from the element's own bounds as it prepaints, so the first +/// frame is already in the right place. +pub fn glide_from(element: impl IntoElement, from_y: Pixels, progress: f32) -> GlideFrom { + GlideFrom { + element: Some(element.into_any_element()), + from_y, + progress, + } +} + +pub struct GlideFrom { + element: Option, + from_y: Pixels, + progress: f32, +} + +impl IntoElement for GlideFrom { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl Element for GlideFrom { + type RequestLayoutState = AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, AnyElement) { + let mut element = self + .element + .take() + .expect("a gliding element is laid out once"); + (element.request_layout(window, cx), element) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + element: &mut AnyElement, + window: &mut Window, + cx: &mut App, + ) { + let remaining = 1. - ease_out_cubic(self.progress.clamp(0., 1.)); + let offset = gpui::point( + px(0.), + ((self.from_y - bounds.origin.y) * remaining).round(), + ); + window.with_element_offset(offset, |window| element.prepaint(window, cx)); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + element.paint(window, cx); + } +} + /// An element rebuilt each frame from a value that eases toward `target` /// and retargets from wherever it is. The value lives in element state /// under `id`, so render code needs no view state. It starts at `target`, diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fbdb0adb6..2b57cdb4b 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -749,6 +749,7 @@ impl Popup { SharedString::from(format!("opened-{}", self.opened)), ), panel: Some(panel), + instant: self.keyboard, placement, priority, } @@ -891,16 +892,18 @@ fn first_side(prefer_first: bool, length: Pixels, first: Pixels, second: Pixels) /// placed, so it lands on the right side in its first frame, and it opens /// from that side: it fades in while settling [`SLIDE`] away from the /// trigger. `id` is new for each opening, so the entrance replays when one -/// menu replaces another. +/// menu replaces another. A menu the keyboard opened appears at once. struct Float { id: ElementId, panel: Option
, + instant: bool, placement: Placement, priority: usize, } -/// When the panel under a [`Float`] id first drew. -struct Opened(std::time::Instant); +/// When the panel under a [`Float`] id first drew, and whether that +/// opening skips the entrance. +struct Opened(std::time::Instant, bool); impl IntoElement for Float { type Element = Self; @@ -954,18 +957,18 @@ impl Element for Float { // Opacity does not reach into a deferred draw, so the entrance is // drawn here, on the panel itself. let now = motion::now(); - let started = window.with_element_state( + let (started, instant) = window.with_element_state( id.expect("a float has an id"), |state: Option, _| { - let started = state.map_or(now, |state| state.0); - (started, Opened(started)) + let opened = state.unwrap_or(Opened(now, self.instant)); + ((opened.0, opened.1), opened) }, ); let shown = motion::ease_out_quint(motion::progress( started, motion::REVEAL, now, - cx.reduce_motion(), + cx.reduce_motion() || instant, )); let mut panel = if shown < 1. { window.request_animation_frame(); @@ -2246,6 +2249,35 @@ mod tests { motion::set_test_now(None); } + /// A menu the keyboard opens on its current choice appears at rest on + /// its first frame; only pointer openings play the entrance. + #[gpui::test] + fn a_menu_the_keyboard_opens_appears_at_once(cx: &mut TestAppContext) { + let start = std::time::Instant::now(); + motion::set_test_now(Some(start)); + let (host, cx) = setup(cx); + cx.update(|_, cx| cx.set_reduce_motion(false)); + click(cx, "a-trigger"); + motion::set_test_now(Some(start + motion::REVEAL)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + let rest = cx.debug_bounds("menu-a").expect("menu").top(); + click(cx, "a-trigger"); + + host.update(cx, |host, cx| { + host.popup.open_highlighted(Which::A, "a-one", cx) + }); + cx.run_until_parked(); + assert_eq!( + cx.debug_bounds("menu-a").expect("menu").top(), + rest, + "drawn at rest, with no slide, on its first frame" + ); + motion::set_test_now(None); + } + #[gpui::test] fn a_menu_opened_on_a_row_shows_that_row(cx: &mut TestAppContext) { let (host, cx) = setup(cx); diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index 1838e8e30..40001f5ae 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -55,10 +55,11 @@ shared 15 fps `ticker` clock and pause in background windows. | Surface | In | Out | |---|---|---| | Hover on buttons, rows, menu items | background eases in, `HOVER` | eases back, `HOVER` | -| Menus, dropdowns, popovers | fade and 4 px slide away from the trigger, `REVEAL` | immediate | +| Menus, dropdowns, popovers | pointer: fade and 4 px slide away from the trigger, `REVEAL`; keyboard: at once | immediate | | Tooltips | fade, `REVEAL`, after the existing delay | immediate | | Dialogs and confirmations | scrim fades, card fades and rises, `ENTER` | immediate | | Screen change (Chat ↔ Settings) and Settings sections | new content fades in, `REVEAL` | immediate | +| Empty state ↔ conversation (first message, opening a task, New Task) | the composer glides from where it was drawn to its new place, `PANEL` | same, reversed | | Switching tasks | transcript fades in when its snapshot lands, `REVEAL` | immediate | | Live transcript rows at the end of the thread (sent message, reply, thinking, tool card, error, stop notice) | height grows from 0 and the row fades in, `ENTER`, so older rows glide up; scrolled up, a fade and 6 px rise | — | | Sent message | the bubble rises in; the real row takes its exact place, and "Sending…" and the waiting dots share one slot under it | — | @@ -95,7 +96,8 @@ appear only after 300 ms, so fast loads never flash a spinner. section). - Menus open from their trigger: the panel's fade and slide are drawn inside the deferred draw, because opacity does not reach into `deferred()`. - Keyboard highlight moves inside a menu settle at once; pointer hover eases. + A menu the keyboard opens appears at once, and keyboard highlight moves + inside a menu settle at once; pointer openings and hover ease. - Transcript rows sit in a `gpui::list`, whose items lose element state when they scroll away. Their entrance is driven by the arrival time kept in view state, so a row animates once and never replays. From 3e0f31ffb0122174e1ca6f4f314ac6ab6b08b65f Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:14:14 -0800 Subject: [PATCH 20/29] Agent: decide a menu's entrance by the input that opened it Settings dropdowns open on their current choice even from a click, so the highlighted-item heuristic skipped their entrance. Use the window's last input instead: a pointer opening slides in, a keyboard opening is drawn at rest. --- apps/maple-agent/app/src/ui/popup.rs | 44 ++++++++++++++++++---------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index 2b57cdb4b..d9937ae67 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -749,7 +749,6 @@ impl Popup { SharedString::from(format!("opened-{}", self.opened)), ), panel: Some(panel), - instant: self.keyboard, placement, priority, } @@ -892,17 +891,18 @@ fn first_side(prefer_first: bool, length: Pixels, first: Pixels, second: Pixels) /// placed, so it lands on the right side in its first frame, and it opens /// from that side: it fades in while settling [`SLIDE`] away from the /// trigger. `id` is new for each opening, so the entrance replays when one -/// menu replaces another. A menu the keyboard opened appears at once. +/// menu replaces another. A menu opened from the keyboard (the window's +/// last input was a key) appears at once; a pointer opening, including one +/// that starts on the current choice, plays the entrance. struct Float { id: ElementId, panel: Option
, - instant: bool, placement: Placement, priority: usize, } -/// When the panel under a [`Float`] id first drew, and whether that -/// opening skips the entrance. +/// When the panel under a [`Float`] id first drew, and whether the +/// keyboard opened it, which skips the entrance. struct Opened(std::time::Instant, bool); impl IntoElement for Float { @@ -957,10 +957,11 @@ impl Element for Float { // Opacity does not reach into a deferred draw, so the entrance is // drawn here, on the panel itself. let now = motion::now(); + let keyboard = window.last_input_was_keyboard(); let (started, instant) = window.with_element_state( id.expect("a float has an id"), |state: Option, _| { - let opened = state.unwrap_or(Opened(now, self.instant)); + let opened = state.unwrap_or(Opened(now, keyboard)); ((opened.0, opened.1), opened) }, ); @@ -2249,32 +2250,45 @@ mod tests { motion::set_test_now(None); } - /// A menu the keyboard opens on its current choice appears at rest on - /// its first frame; only pointer openings play the entrance. + /// The input that opened a menu decides its entrance. With the pointer + /// it slides in, even when its owner starts it on the current choice + /// the way Settings dropdowns do (`open_highlighted`); from the + /// keyboard it is drawn at rest on its first frame. #[gpui::test] - fn a_menu_the_keyboard_opens_appears_at_once(cx: &mut TestAppContext) { + fn only_pointer_openings_play_the_menu_entrance(cx: &mut TestAppContext) { let start = std::time::Instant::now(); motion::set_test_now(Some(start)); let (host, cx) = setup(cx); cx.update(|_, cx| cx.set_reduce_motion(false)); + let top = |cx: &mut VisualTestContext| cx.debug_bounds("menu-a").expect("menu").top(); click(cx, "a-trigger"); motion::set_test_now(Some(start + motion::REVEAL)); cx.update(|window, cx| { window.simulate_next_frame(cx); }); cx.run_until_parked(); - let rest = cx.debug_bounds("menu-a").expect("menu").top(); + let rest = top(cx); click(cx, "a-trigger"); + // The pointer was last; a click handler opens on the current row. + let away = cx.debug_bounds("b-trigger").expect("trigger").center(); + cx.simulate_mouse_move(away, None, Modifiers::default()); host.update(cx, |host, cx| { host.popup.open_highlighted(Which::A, "a-one", cx) }); cx.run_until_parked(); - assert_eq!( - cx.debug_bounds("menu-a").expect("menu").top(), - rest, - "drawn at rest, with no slide, on its first frame" - ); + assert_eq!(rest - top(cx), SLIDE, "a pointer opening slides in"); + host.update(cx, |host, cx| { + host.popup.close(cx); + }); + cx.run_until_parked(); + + cx.simulate_keystrokes("escape"); + host.update(cx, |host, cx| { + host.popup.open_highlighted(Which::A, "a-one", cx) + }); + cx.run_until_parked(); + assert_eq!(top(cx), rest, "a keyboard opening is drawn at rest"); motion::set_test_now(None); } From 8edbf3834061f454dd7552222098c404fc1c0091 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:33:27 -0800 Subject: [PATCH 21/29] Agent: the sent message opens its slot instead of jumping the transcript The message being sent and the waiting dots sit in one slot under the transcript, outside the list. When it appears, or its content grows, it now opens over 180 ms with its content pinned to the bottom, so the transcript above glides up rather than jumping. It still shrinks at once, and the row that takes its place opens from the space it leaves, so the hand-off stays still. A task switch or history load shows it at once, and reduced motion settles it immediately. --- apps/maple-agent/app/src/ui/chat/mod.rs | 32 +--- .../maple-agent/app/src/ui/chat/transcript.rs | 180 +++++++++++++++++- 2 files changed, 173 insertions(+), 39 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 5ca77486e..fa2bcde80 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -6202,32 +6202,6 @@ impl ChatScreen { self.has_send_feedback() || (self.awaiting_first_token && self.is_run_active()) } - /// The slot under the transcript: the message being sent with its - /// status line, or the waiting dots, both `WAITING_ROW_HEIGHT` tall so - /// one taking over from the other moves nothing. - pub(super) fn render_under_transcript( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> [Option; 2] { - let preview = self.render_send_preview(window, cx); - let dots = self.awaiting_first_token && self.is_run_active() && !self.has_send_feedback(); - self.row_motion.slot_on_screen = preview.is_some() || dots; - [ - preview.map(IntoElement::into_any_element), - // Same gutter as transcript text so the dots line up. - dots.then(|| { - div() - .w_full() - .max_w(CONTENT_WIDTH) - .mx_auto() - .px_6() - .child(render_waiting_indicator()) - .into_any_element() - }), - ] - } - /// One timeline item arrived for the selected session. fn apply_incoming_item( &mut self, @@ -7270,11 +7244,7 @@ impl Render for ChatScreen { // takes scrolls the transcript. .on_scroll_wheel(cx.listener(Self::scroll_transcript_from_pane)) .child(self.render_transcript(window, cx)) - .children( - self.render_under_transcript(window, cx) - .into_iter() - .flatten(), - ) + .children(self.render_under_transcript(window, cx)) .when_some(self.current_question(), |container, question| { let input = self.pending_question_input.clone(); let step = self.question_step; diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 9068dc06f..8a6b0c2f5 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -389,6 +389,122 @@ impl ChatScreen { "jump-to-latest-reveal", )) } + + /// The slot under the transcript: the message being sent with its + /// status line, or the waiting dots, both `WAITING_ROW_HEIGHT` tall so + /// one taking over from the other moves nothing. + /// + /// It sits outside the list, so its height is taken from the list's + /// viewport. When it appears, or its content grows, it opens over + /// `ENTER` instead of all at once, and the transcript above glides up. + /// Its content stays pinned to its bottom while the top is clipped. It + /// shrinks at once: the row taking its place opens from the space it + /// leaves. + pub(super) fn render_under_transcript( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let appeared = !self.row_motion.slot_on_screen; + let snap = std::mem::take(&mut self.row_motion.slot_snap); + let preview = self.render_send_preview(window, cx); + let dots = self.awaiting_first_token && self.is_run_active() && !self.has_send_feedback(); + let slot = &mut self.row_motion; + slot.slot_on_screen = preview.is_some() || dots; + if !slot.slot_on_screen { + slot.slot_open = None; + slot.slot_natural.set(None); + slot.slot_shown.set(px(0.)); + return None; + } + let now = motion::now(); + let reduced = cx.reduce_motion(); + // Measured last frame; `None` on the frame it appears. + let natural = slot.slot_natural.get().map(f32::from); + if reduced { + slot.slot_open = None; + } else if appeared { + // A send opens it; a task switch or history load shows it at + // once. + slot.slot_open = (!snap).then(|| motion::Tween::new(0.)); + slot.slot_since = Some(now); + } + let cap = match (&mut slot.slot_open, natural) { + (None, None) => None, + (None, Some(natural)) => { + if !reduced { + slot.slot_open = Some(motion::Tween::new(natural)); + } + None + } + // Closed on its first frame while its content is measured. + (Some(open), None) => { + window.request_animation_frame(); + Some(px(open.value(now))) + } + (Some(open), Some(natural)) => { + // Shrinking follows the content at once; growing opens. + // The first opening runs from when the slot appeared, not + // from the frame its content was first measured. + if natural < open.value(now) { + *open = motion::Tween::new(natural); + } + let start = match slot.slot_since.take() { + Some(since) if open.target() == 0. => since, + _ => now, + }; + open.set( + natural, + motion::ENTER, + motion::ease_out_cubic, + start, + reduced, + ); + if open.is_moving(now) { + window.request_animation_frame(); + } + Some(px(open.value(now))) + } + }; + let natural_height = slot.slot_natural.clone(); + let shown_height = slot.slot_shown.clone(); + let content = div() + .flex_none() + .w_full() + .flex() + .flex_col() + .children(preview) + .when(dots, |content| { + // Same gutter as transcript text so the dots line up. + content.child( + div() + .w_full() + .max_w(CONTENT_WIDTH) + .mx_auto() + .px_6() + .child(render_waiting_indicator()), + ) + }); + Some( + div() + .flex_none() + .w_full() + .flex() + .flex_col() + .justify_end() + .overflow_hidden() + .when_some(cap, |slot, cap| slot.max_h(cap)) + .child(content) + .on_children_prepainted(move |bounds, _, _| { + if let Some(content) = bounds.first() { + let height = content.size.height; + natural_height.set(Some(height)); + shown_height.set(cap.map_or(height, |cap| height.min(cap))); + } + }) + .into_any_element(), + ) + } } /// Height of the row under the transcript that shows the waiting dots or, @@ -416,9 +532,10 @@ pub(super) struct RowMotion { disclosed: HashMap, /// When another task's transcript landed. switched: Option, - /// The user row that takes over from the send preview on screen, and - /// where the preview's bubble was. - handed_off: Option<(String, Option>)>, + /// The user row that takes over from the send preview on screen, + /// where the preview's bubble was, and how tall the slot under the + /// transcript was then. + handed_off: Option<(String, Option>, Pixels)>, /// Where the send preview's bubble painted last frame, if it showed. pub(super) preview_body: Rc>>>, /// Rows apply without an entrance (events replayed on reconnect). @@ -426,6 +543,17 @@ pub(super) struct RowMotion { /// The slot under the transcript (waiting dots or the sent message's /// status) showed last frame. pub(super) slot_on_screen: bool, + /// The slot's height cap while it opens and after, so it grows rather + /// than appearing at full height; `None` draws it at its natural height. + slot_open: Option, + /// The slot's natural height and the height it drew, last frame. + slot_natural: Rc>>, + slot_shown: Rc>, + /// The next slot to appear shows at once: it came with a task switch + /// or a history load, not a send. + slot_snap: bool, + /// When the slot last appeared, for its first opening. + slot_since: Option, } #[derive(Clone, Copy)] @@ -463,13 +591,14 @@ impl RowMotion { } let grow = match handed_off { // The preview's bubble becomes this row: it opens from the - // space the bubble left and glides from where it was. - Some((handed, body)) if handed == id => { + // space the bubble left (all of the slot but the status line + // that stays) and glides from where the bubble was. + Some((handed, body, slot)) if handed == id => { let Some(body) = body.filter(|_| at_tail) else { return; }; Some(Grow { - from: body.size.height + PREVIEW_GAP, + from: (slot - WAITING_ROW_HEIGHT).max(px(0.)), glide_from: Some(body.origin.y), }) } @@ -495,7 +624,7 @@ impl RowMotion { /// The next row with `id` replaces the send preview on screen. pub(super) fn hand_off(&mut self, id: String) { - self.handed_off = Some((id, self.preview_body.get())); + self.handed_off = Some((id, self.preview_body.get(), self.slot_shown.get())); } /// Apply rows without an entrance until called again with `false`. @@ -537,6 +666,7 @@ impl RowMotion { self.heights.borrow_mut().clear(); self.disclosed.clear(); self.handed_off = None; + self.slot_snap = true; } /// The row's entrance, or `None` for a row that did not arrive live or @@ -2532,7 +2662,7 @@ mod motion_tests { .flex() .flex_col() .child(transcript) - .children(under.into_iter().flatten()) + .children(under) }); div().w(px(1200.)).h(px(800.)).flex().flex_col().child(pane) } @@ -2747,6 +2877,40 @@ mod motion_tests { assert_eq!(status.size.height, WAITING_ROW_HEIGHT); } + #[gpui::test] + fn sending_opens_the_slot_so_older_rows_glide_up(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 30); + let before = bounds(cx, "timeline-row-h29"); + chat.update(cx, |chat, _| { + let preview = sending::SendPreview::new(Some("s1".into()), "hello", &[]) + .capture_timeline(&chat.timeline); + chat.sending_previews.insert("send-1".into(), preview); + }); + let tops = tops_through_entrance(&chat, &clock, Duration::ZERO, &["timeline-row-h29"], cx) + .remove(0); + assert!( + (tops[0] - before.origin.y).abs() <= px(1.), + "older rows hold still on the send frame: {before:?} then {tops:?}" + ); + assert!(tops[tops.len() - 1] < before.origin.y, "the slot made room"); + assert!( + tops.windows(2).all(|pair| pair[1] <= pair[0]), + "older rows only rise: {tops:?}" + ); + assert_glides(&tops, "an older row"); + let older = bounds(cx, "timeline-row-h29"); + let bubble = bounds(cx, "pending-send-body"); + let status = bounds(cx, "pending-send-status"); + + // The settled layout is the one without motion. + cx.update(|_, cx| cx.set_reduce_motion(true)); + redraw(&chat, cx); + assert_eq!(bounds(cx, "timeline-row-h29"), older); + assert_eq!(bounds(cx, "pending-send-body"), bubble); + assert_eq!(bounds(cx, "pending-send-status"), status); + } + #[gpui::test] fn the_real_row_takes_over_from_the_bubble_without_a_jump(cx: &mut TestAppContext) { let clock = Clock::start(); From 88cdef5df750874628196e5ee4028f124d790349 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:56:44 -0800 Subject: [PATCH 22/29] Agent: screen and Settings fades cover the content instead of fading it gpui replays a cached view's last paint with the opacity it was painted at, so fading the whole screen left the cached sidebar stuck at its first, nearly transparent frame after returning from Settings. swap_in now fades a cover in the background color over content that always paints at full opacity. Document the rule for cached views. --- apps/maple-agent/CLAUDE.md | 4 +- apps/maple-agent/app/src/desktop.rs | 74 +++++++++++++++++++ apps/maple-agent/app/src/ui/chat/computers.rs | 10 +++ apps/maple-agent/app/src/ui/motion.rs | 34 +++++++-- apps/maple-agent/app/src/ui/settings.rs | 1 + apps/maple-agent/docs/motion.md | 13 +++- 6 files changed, 124 insertions(+), 12 deletions(-) diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index 084ed055c..e148e6cbb 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -125,7 +125,9 @@ tokens and rules. Motion never delays input, retargets from the value on screen, closes menus and dialogs at once, and settles immediately under reduced motion. Streaming text, typing, and keyboard navigation do not animate. Rows inside `gpui::list` time their entrance from view state, not -element state, so they never replay when scrolled back into view. +element state, so they never replay when scrolled back into view. Never put +opacity on an ancestor of a cached view: its replayed paint keeps the +opacity of its first frame. Fade a cover over it instead, as `swap_in` does. ## Accessibility diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 31a3fd4b2..00a1127e8 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -2439,6 +2439,7 @@ impl Render for MapleApp { }), "maple-screen", screen_kind, + gpui::rgb(ui::theme::bg_app()), )); #[cfg(target_os = "linux")] let root = root.children(self.restart_confirmation.clone()); @@ -5286,6 +5287,79 @@ mod tests { assert!(return_to_window(true, cx)); } + /// Settings and back brings the chat's sidebar back with its task rows. + /// The screen fade covers the whole window, so a regression there would + /// show as a sidebar whose rows never return. + #[gpui::test] + fn settings_and_back_keeps_the_sidebar_task_rows(cx: &mut gpui::TestAppContext) { + struct Host(Entity); + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(self.0.clone()) + } + } + let settings = crate::settings::SETTINGS_IO_LOCK.lock(); + let app = project_shell_locked(cx, &settings); + let chat = app.read_with(cx, |app, _| app.local_chat.clone().expect("local chat")); + chat.update(cx, |chat, cx| { + chat.seed_sessions_for_test( + vec![maple_agent::agent::AgentSessionSummary { + web_enabled: true, + permission_mode: Default::default(), + state: maple_agent::agent::AgentTaskState::Active, + acp: false, + id: "s1".into(), + title: "First task".into(), + project_root: "/same/project".into(), + created_ms: 0, + updated_ms: 0, + message_count: 1, + model: None, + }], + cx, + ) + }); + let (_, cx) = cx.add_window_view(|_, _| Host(app.clone())); + cx.simulate_resize(size(px(1100.), px(850.))); + cx.run_until_parked(); + assert!( + cx.debug_bounds("session-s1").is_some(), + "the task row shows" + ); + + app.update(cx, |app, cx| app.show_settings(Section::General, cx)); + cx.run_until_parked(); + assert!( + cx.debug_bounds("session-s1").is_none(), + "Settings replaces the chat" + ); + let snapshot = app.read_with(cx, |app, cx| match &app.screen { + Screen::Settings(screen) => screen.read(cx).settings_snapshot(), + _ => panic!("expected Settings"), + }); + let start = std::time::Instant::now(); + ui::motion::set_test_now(Some(start)); + app.update(cx, |app, cx| app.close_settings(snapshot, cx)); + cx.run_until_parked(); + // Let the screen fade and anything the sidebar eases settle. + for step in 1..=4 { + ui::motion::set_test_now(Some(start + ui::motion::PANEL * step)); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + } + // A cached view replays its last paint without recording debug + // bounds, so draw every view afresh before looking for the row. + cx.update(|_, cx| cx.refresh_windows()); + cx.run_until_parked(); + assert!( + cx.debug_bounds("session-s1").is_some(), + "the task row is back after Settings" + ); + ui::motion::set_test_now(None); + } + #[gpui::test] fn a_free_guest_gets_its_account_id_in_the_gate(cx: &mut gpui::TestAppContext) { let settings = crate::settings::SETTINGS_IO_LOCK.lock(); diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs index 16b38b14b..9634cd4b7 100644 --- a/apps/maple-agent/app/src/ui/chat/computers.rs +++ b/apps/maple-agent/app/src/ui/chat/computers.rs @@ -177,6 +177,16 @@ impl ChatScreen { } } + #[cfg(test)] + pub(crate) fn seed_sessions_for_test( + &mut self, + sessions: Vec, + cx: &mut Context, + ) { + self.sessions = sessions; + self.sync_sidebar(cx); + } + pub(crate) fn finish_project_rename( &mut self, target: ProjectTarget, diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 168dc3c50..c86d7d7e4 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -1003,15 +1003,26 @@ impl Element for HoverBackground { /// Content that replaces other content in one place, such as the window's /// screen or a Settings section: whenever `key` changes, the new content /// fades in over [`REVEAL`]. Its first appearance does not fade, so a -/// first paint is never held back. `id` must be stable across frames; the -/// fade sets the element's opacity while it runs. -pub fn swap_in(element: E, id: impl Into, key: impl Into) -> Swap +/// first paint is never held back. `id` must be stable across frames. +/// +/// The content itself always paints at full opacity; a `cover` in the +/// color behind it, laid over it as its last child, fades out instead. +/// gpui replays a cached view's last paint, opacity included, so fading +/// an ancestor of one (the sidebar) would leave it stuck at its first, +/// nearly transparent frame. +pub fn swap_in( + element: E, + id: impl Into, + key: impl Into, + cover: impl Into, +) -> Swap where - E: IntoElement + Styled + 'static, + E: IntoElement + ParentElement + 'static, { Swap { id: id.into(), key: key.into(), + cover: cover.into(), element: Some(element), } } @@ -1019,6 +1030,7 @@ where pub struct Swap { id: ElementId, key: ElementId, + cover: gpui::Hsla, element: Option, } @@ -1028,7 +1040,7 @@ struct Swapped { started: Option, } -impl IntoElement for Swap { +impl IntoElement for Swap { type Element = Self; fn into_element(self) -> Self { @@ -1036,7 +1048,7 @@ impl IntoElement for Swap { } } -impl Element for Swap { +impl Element for Swap { type RequestLayoutState = AnyElement; type PrepaintState = (); @@ -1081,7 +1093,15 @@ impl Element for Swap { let element = self.element.take().expect("a swap is laid out once"); let mut element = if opacity < 1. { window.request_animation_frame(); - element.opacity(opacity).into_any_element() + element + .child( + gpui::div() + .absolute() + .inset_0() + .bg(self.cover) + .opacity(1. - opacity), + ) + .into_any_element() } else { element.into_any_element() }; diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 8ebbaf35f..5fd9f3630 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -1768,6 +1768,7 @@ impl Render for SettingsScreen { self.render_pane(window, cx), "settings-pane-swap", self.section.id(), + gpui::rgb(theme::bg_app()), )) .child(crate::ui::scrollbar::scrollbar( "settings-scrollbar", diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index 40001f5ae..7fd3bc741 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -90,10 +90,15 @@ appear only after 300 ms, so fast loads never flash a spinner. that owns no state (chevrons, switches, icon swaps). It never animates on mount. - `fade_in`, `rise_in` and `slide_in` are one-shot entrances keyed by element - id; a new id replays them. `swap_in(element, id, key)` fades content in - whenever `key` changes but draws its first appearance at once, for content - that replaces other content in one place (the window's screen, a Settings - section). + id; a new id replays them. `swap_in(element, id, key, cover)` fades content + in whenever `key` changes but draws its first appearance at once, for + content that replaces other content in one place (the window's screen, a + Settings section). +- Never fade an ancestor of a cached view (`Entity::cached`, the sidebar): + gpui replays a cached view's last paint with the opacity it was painted + at, so it would stay as faint as its first frame. `swap_in` fades a cover + in the background color over the content instead, so the content always + paints at full opacity. - Menus open from their trigger: the panel's fade and slide are drawn inside the deferred draw, because opacity does not reach into `deferred()`. A menu the keyboard opens appears at once, and keyboard highlight moves From 045e35585476bff5dd0e1084148432f7ec51a660 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:42:21 -0800 Subject: [PATCH 23/29] Agent: paint the swap cover over the content's bounds The cover was a child of the swapped element, so in a scrolled Settings pane it moved with the content and left a band uncovered. Paint it as a quad over the element's bounds after the content instead: no extra layout node, and swap_in takes any element. Note the sidebar collapse as the one safe fade over a cached view. --- apps/maple-agent/CLAUDE.md | 4 +- apps/maple-agent/app/src/ui/motion.rs | 61 +++++++++++++-------------- apps/maple-agent/docs/motion.md | 8 +++- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index e148e6cbb..af5880640 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -127,7 +127,9 @@ reduced motion. Streaming text, typing, and keyboard navigation do not animate. Rows inside `gpui::list` time their entrance from view state, not element state, so they never replay when scrolled back into view. Never put opacity on an ancestor of a cached view: its replayed paint keeps the -opacity of its first frame. Fade a cover over it instead, as `swap_in` does. +opacity of its first frame. Paint a cover over it instead, as `swap_in` +does, unless its bounds or content mask change on every frame of the fade +and again at rest, as in the sidebar collapse. ## Accessibility diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index c86d7d7e4..efd4e7360 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -1006,10 +1006,10 @@ impl Element for HoverBackground { /// first paint is never held back. `id` must be stable across frames. /// /// The content itself always paints at full opacity; a `cover` in the -/// color behind it, laid over it as its last child, fades out instead. -/// gpui replays a cached view's last paint, opacity included, so fading -/// an ancestor of one (the sidebar) would leave it stuck at its first, -/// nearly transparent frame. +/// color behind it, painted over its bounds, fades out instead. gpui +/// replays a cached view's last paint, opacity included, so fading an +/// ancestor of one (the sidebar) would leave it stuck at its first, nearly +/// transparent frame. pub fn swap_in( element: E, id: impl Into, @@ -1017,7 +1017,7 @@ pub fn swap_in( cover: impl Into, ) -> Swap where - E: IntoElement + ParentElement + 'static, + E: IntoElement + 'static, { Swap { id: id.into(), @@ -1040,7 +1040,7 @@ struct Swapped { started: Option, } -impl IntoElement for Swap { +impl IntoElement for Swap { type Element = Self; fn into_element(self) -> Self { @@ -1048,8 +1048,9 @@ impl IntoElement for Swap { } } -impl Element for Swap { - type RequestLayoutState = AnyElement; +impl Element for Swap { + // The content, and how much of it shows through the cover. + type RequestLayoutState = (AnyElement, f32); type PrepaintState = (); fn id(&self) -> Option { @@ -1066,11 +1067,11 @@ impl Element for Swap { _inspector_id: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, - ) -> (LayoutId, AnyElement) { + ) -> (LayoutId, (AnyElement, f32)) { let now = now(); let reduced = cx.reduce_motion(); let key = &self.key; - let opacity = window.with_element_state( + let shown = window.with_element_state( id.expect("swaps have an id"), |state: Option, _| { let mut state = state.unwrap_or_else(|| Swapped { @@ -1081,31 +1082,24 @@ impl Element for Swap { state.key = key.clone(); state.started = Some(now); } - let opacity = state.started.map_or(1., |started| { + let shown = state.started.map_or(1., |started| { ease_out_quint(progress(started, REVEAL, now, reduced)) }); - if opacity >= 1. { + if shown >= 1. { state.started = None; } - (opacity, state) + (shown, state) }, ); - let element = self.element.take().expect("a swap is laid out once"); - let mut element = if opacity < 1. { + if shown < 1. { window.request_animation_frame(); - element - .child( - gpui::div() - .absolute() - .inset_0() - .bg(self.cover) - .opacity(1. - opacity), - ) - .into_any_element() - } else { - element.into_any_element() - }; - (element.request_layout(window, cx), element) + } + let mut element = self + .element + .take() + .expect("a swap is laid out once") + .into_any_element(); + (element.request_layout(window, cx), (element, shown)) } fn prepaint( @@ -1113,7 +1107,7 @@ impl Element for Swap { _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, _bounds: Bounds, - element: &mut AnyElement, + (element, _): &mut (AnyElement, f32), window: &mut Window, cx: &mut App, ) { @@ -1124,13 +1118,18 @@ impl Element for Swap { &mut self, _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, - _bounds: Bounds, - element: &mut AnyElement, + bounds: Bounds, + (element, shown): &mut (AnyElement, f32), _: &mut (), window: &mut Window, cx: &mut App, ) { element.paint(window, cx); + if *shown < 1. { + let mut cover = self.cover; + cover.a *= 1. - *shown; + window.paint_quad(gpui::fill(bounds, cover)); + } } } diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index 7fd3bc741..da2e61ea9 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -96,9 +96,13 @@ appear only after 300 ms, so fast loads never flash a spinner. Settings section). - Never fade an ancestor of a cached view (`Entity::cached`, the sidebar): gpui replays a cached view's last paint with the opacity it was painted - at, so it would stay as faint as its first frame. `swap_in` fades a cover + at, so it would stay as faint as its first frame. `swap_in` paints a cover in the background color over the content instead, so the content always - paints at full opacity. + paints at full opacity. The one exception is a fade during which the + cached view's bounds or content mask change every frame and change again + when it settles: the sidebar collapse clips its column only while it + moves, which forces a fresh paint each frame and a full-opacity one at + rest. - Menus open from their trigger: the panel's fade and slide are drawn inside the deferred draw, because opacity does not reach into `deferred()`. A menu the keyboard opens appears at once, and keyboard highlight moves From 4099642742db210c8db959dfb5e8c62c4eee8cf2 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:24:15 -0800 Subject: [PATCH 24/29] Agent: open the first send's slot in a new task, and fix review nits The empty state never drew the slot under the transcript, so the snap a blank transcript sets carried over to the first send, and its slot appeared at full height instead of opening. Clear it while the empty state shows, and test it on the whole screen. - Reconnect replays go through one method; its test drives that method instead of toggling the quiet flag by hand. - The copy button hovers with the translucent overlay it had before, so its plate suits tinted bubbles and code blocks. - Docs: dialog cards rise 8 px, notices use ENTER, and a slid element's hitbox moves with its paint. --- apps/maple-agent/app/src/ui/chat/mod.rs | 20 +++--- .../maple-agent/app/src/ui/chat/transcript.rs | 62 ++++++++++++++++++- apps/maple-agent/app/src/ui/motion.rs | 4 +- apps/maple-agent/app/src/ui/widgets.rs | 2 +- apps/maple-agent/docs/motion.md | 9 +-- 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index fa2bcde80..db819aa84 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -6261,6 +6261,15 @@ impl ChatScreen { self.refresh_context_usage(cx); } } + + /// Pushes that crossed a reconnect rebuild the transcript; they do not + /// enter like live rows. + fn replay_after_reconnect(&mut self, events: Vec, cx: &mut Context) { + self.row_motion.set_quiet(true); + self.handle_service_events(events, cx); + self.row_motion.set_quiet(false); + } + pub fn handle_service_events( &mut self, events: Vec, @@ -6458,11 +6467,7 @@ impl ChatScreen { } } this.refresh_sessions(cx); - // Pushes that crossed the reconnect rebuild the - // transcript; they do not enter like live rows. - this.row_motion.set_quiet(true); - this.handle_service_events(events, cx); - this.row_motion.set_quiet(false); + this.replay_after_reconnect(events, cx); this.host_recovery.unavailable = false; this.runtime_error_reason = None; this.runtime_error = None; @@ -7230,8 +7235,9 @@ impl Render for ChatScreen { let rise = gpui::point(px(0.), motion::RISE); let composer_resize = self.composer_resize_frame(window, cx); let main = if empty { - // No slot under a transcript here; rows opening from it start at 0. - self.row_motion.slot_on_screen = false; + // No slot under a transcript here; rows opening from it start at + // 0, and the next slot to appear comes with a send. + self.row_motion.show_empty_state(); self.render_empty_state(composer_resize, composer_glide, window, cx) } else { div() diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 8a6b0c2f5..ac5366c04 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -669,6 +669,13 @@ impl RowMotion { self.slot_snap = true; } + /// The empty state shows: nothing sits under a transcript, and the next + /// slot to appear comes with a send, so it opens. + pub(super) fn show_empty_state(&mut self) { + self.slot_on_screen = false; + self.slot_snap = false; + } + /// The row's entrance, or `None` for a row that did not arrive live or /// has settled. pub(super) fn entrance(&self, id: &str, now: Instant, reduced: bool) -> Option { @@ -2911,6 +2918,46 @@ mod motion_tests { assert_eq!(bounds(cx, "pending-send-status"), status); } + #[gpui::test] + fn the_first_send_in_an_empty_task_opens_its_slot(cx: &mut TestAppContext) { + // The whole screen, so the empty state draws in place of the + // transcript until the first send. + struct ScreenHost { + chat: Entity, + } + impl Render for ScreenHost { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().flex().flex_col().child(self.chat.clone()) + } + } + let clock = Clock::start(); + let chat = screen(cx); + // A new task: a blank transcript replaced the last one. + chat.update(cx, |chat, _| chat.replace_timeline(Vec::new())); + let (_host, cx) = cx.add_window_view(|_, _| ScreenHost { chat: chat.clone() }); + cx.simulate_resize(gpui::size(px(1200.), px(800.))); + cx.run_until_parked(); + assert!(chat.read_with(cx, |chat, _| chat.shows_empty_state())); + chat.update(cx, |chat, _| { + let preview = sending::SendPreview::new(Some("s1".into()), "hello", &[]) + .capture_timeline(&chat.timeline); + chat.sending_previews.insert("send-1".into(), preview); + }); + redraw(&chat, cx); + let shown = |chat: &Entity, cx: &mut gpui::VisualTestContext| { + chat.read_with(cx, |chat, _| chat.row_motion.slot_shown.get()) + }; + let sent = shown(&chat, cx); + clock.at(motion::ENTER * 2); + redraw(&chat, cx); + let open = shown(&chat, cx); + assert!(open > px(0.), "the slot shows its status"); + assert!( + sent < open, + "the send opens the slot rather than showing it at full height: {sent:?} then {open:?}" + ); + } + #[gpui::test] fn the_real_row_takes_over_from_the_bubble_without_a_jump(cx: &mut TestAppContext) { let clock = Clock::start(); @@ -3013,9 +3060,18 @@ mod motion_tests { assert_eq!(chat.row_motion.arrival("paged", now, false), 1.); chat.pages.after = false; // Pushes replayed after a reconnect rebuild the transcript. - chat.row_motion.set_quiet(true); - chat.apply_incoming_item("s2", item("replayed", "message", Some("x")), cx); - chat.row_motion.set_quiet(false); + chat.replay_after_reconnect( + vec![AgentServiceEvent::TimelineItem { + session_id: "s2".into(), + run_id: None, + item: item("replayed", "message", Some("x")), + }], + cx, + ); + assert!( + chat.timeline.iter().any(|row| row.id == "replayed"), + "the replayed push landed" + ); assert_eq!(chat.row_motion.arrival("replayed", now, false), 1.); assert!(!chat.row_motion.settle(now, false)); // Reduced motion: a live row is at rest at once. diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index efd4e7360..00acd20ad 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -352,8 +352,8 @@ where } /// Fade in over `duration` while moving from `from`, an offset from the -/// resting position, to rest. Layout and hit testing use the resting -/// position throughout. +/// resting position, to rest. Layout uses the resting position throughout; +/// the hitbox moves with the paint (see [`shifted`]). pub fn slide_in( element: E, id: impl Into, diff --git a/apps/maple-agent/app/src/ui/widgets.rs b/apps/maple-agent/app/src/ui/widgets.rs index 005da7261..81d6e8cad 100644 --- a/apps/maple-agent/app/src/ui/widgets.rs +++ b/apps/maple-agent/app/src/ui/widgets.rs @@ -767,7 +767,7 @@ pub fn copy_button( super::motion::hover_background( button, gpui::transparent_black(), - gpui::rgb(theme::bg_sidebar_row_hover()), + theme::overlay_hover(), ) }) } diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index da2e61ea9..7aacc8b58 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -19,8 +19,9 @@ between the user and the next action. This page records the shared decisions; close at once: dismissal is the user's decision and the next action must not wait for it. Reversible state changes run the same curve both ways. - **Opacity first, small distances.** gpui cannot scale or transform a `div`, - so content enters with opacity and at most a 6 px rise, drawn as a - paint offset that does not change layout. Only SVG icons scale or rotate. + so content enters with opacity and a short rise (6 px; 8 px for dialog + cards), drawn as a paint offset: layout stays put, and the hitbox moves + with the paint. Only SVG icons scale or rotate. Height animates only where a box truly changes size: text fields, the expanded composer, the sidebar, and a row arriving at the end of the thread, which opens so older rows glide up rather than jump. @@ -40,9 +41,9 @@ between the user and the next action. This page records the shared decisions; | Token | Duration | Curve | Used for | |---|---|---|---| | `HOVER` | 120 ms | smoothstep | hover plates, controls that appear on hover | -| `REVEAL` | 140 ms | ease-out quint | menus, popovers, tooltips, notices, page and pane changes | +| `REVEAL` | 140 ms | ease-out quint | menus, popovers, tooltips, page and pane changes | | `TOGGLE` | 150 ms | ease-out cubic | switches, checkboxes, chevrons, icon swaps, status changes | -| `ENTER` | 180 ms | ease-out quint | live transcript rows, cards above the composer, dialogs | +| `ENTER` | 180 ms | ease-out quint | live transcript rows, notices and cards above the composer, dialogs | | `PANEL` | 200 ms | ease-out cubic | sidebar, composer expand and shrink | | `resize_duration` | 120–200 ms | ease-out cubic | text fields growing and shrinking with their text, by distance | From 09fed4cef31ff4229129f18e5f53be947a302c26 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:56:30 -0800 Subject: [PATCH 25/29] Agent: a run error or failed send takes the slot's place, and questions rise in When a run ended in an error, or a send failed, the slot under the transcript (the waiting dots, or the sent message and its status) vanished at once while the error grew from nothing, so the thread dropped by the slot's height and then rose again. - A run error goes through the same path as every other live row: it ends the wait and opens from the waiting row's space. - A failed send's error opens from the whole slot's height. - The question card rises in like the other cards above the composer. - Docs: covers and fades never block input; rows that replace the slot open from its height. --- apps/maple-agent/app/src/ui/chat/mod.rs | 39 ++++-- .../maple-agent/app/src/ui/chat/transcript.rs | 118 ++++++++++++++++++ apps/maple-agent/docs/motion.md | 7 +- 3 files changed, 153 insertions(+), 11 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index db819aa84..689013983 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -5530,6 +5530,14 @@ impl ChatScreen { if let Some(session) = self.selected_session.clone() { let id = item.id.clone(); if self.apply_timeline_item(&session, item).is_some() { + // A failed send's message and status leave the slot under + // the transcript as its error arrives: the error opens from + // all of that space, so nothing above it jumps. + if self.row_motion.slot_on_screen && !self.waiting_slot_shown() { + self.row_motion.slot_on_screen = false; + let space = self.row_motion.slot_height(); + self.row_motion.open_from_more(&id, space); + } self.local_error_text.insert(id, presentation); } } @@ -6966,8 +6974,9 @@ impl ChatScreen { } AgentRunEvent::Error(item) => { if self.is_selected(session_id) { - self.load_attachment_images_for(&item, cx); - self.apply_timeline_item(session_id, item); + // Like any reply, an error ends the wait: the waiting + // row leaves and the error opens from its space. + self.apply_incoming_item(session_id, item, cx); } else { self.bump_timeline_revision(session_id); return false; @@ -7254,14 +7263,24 @@ impl Render for ChatScreen { .when_some(self.current_question(), |container, question| { let input = self.pending_question_input.clone(); let step = self.question_step; - container.child(render_question_card( - question, - step, - self.question_collapsed, - input, - &self.question_selected, - &self.messages, - cx, + let enter = gpui::ElementId::Name( + format!("question-card-enter-{}", question.request_id).into(), + ); + // Like the cards above the composer, a new question + // rises in and leaves at once. + container.child(motion::slide_in( + render_question_card( + question, + step, + self.question_collapsed, + input, + &self.question_selected, + &self.messages, + cx, + ), + enter, + motion::ENTER, + rise, )) }) .child(composer::glide( diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index ac5366c04..ad9f6a5d1 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -622,6 +622,11 @@ impl RowMotion { } } + /// The slot's height on screen last frame. + pub(super) fn slot_height(&self) -> Pixels { + self.slot_shown.get() + } + /// The next row with `id` replaces the send preview on screen. pub(super) fn hand_off(&mut self, id: String) { self.handed_off = Some((id, self.preview_body.get(), self.slot_shown.get())); @@ -2958,6 +2963,119 @@ mod motion_tests { ); } + /// The newest history row's top before `land`, and through the entrance + /// of the row it adds, once the slot under the transcript is open. + fn older_row_through_error( + chat: &Entity, + clock: &Clock, + land: impl FnOnce(&mut ChatScreen, &mut Context), + cx: &mut gpui::VisualTestContext, + ) -> (Pixels, Vec) { + // The slot appears, then opens over ENTER. + redraw(chat, cx); + let start = motion::ENTER * 2; + clock.at(start); + redraw(chat, cx); + assert!( + chat.read_with(cx, |chat, _| chat.row_motion.slot_shown.get()) > px(0.), + "the slot is open before the error" + ); + let before = bounds(cx, "timeline-row-h29").origin.y; + chat.update(cx, land); + let tops = tops_through_entrance(chat, clock, start, &["timeline-row-h29"], cx).remove(0); + (before, tops) + } + + #[gpui::test] + fn a_run_error_takes_the_waiting_rows_place(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 30); + // A run is going and nothing has come back yet: the waiting dots show. + chat.update(cx, |chat, _| { + chat.active_runs.insert("s1".into(), "run-1".into()); + chat.awaiting_first_token = true; + }); + let (before, tops) = older_row_through_error( + &chat, + &clock, + |chat, cx| { + for event in [ + maple_agent::agent::AgentRunEvent::Error(item( + "failed", + "error", + Some("The model is unavailable."), + )), + maple_agent::agent::AgentRunEvent::Finished( + maple_agent::agent::AgentRunTerminal::Completed, + ), + ] { + chat.handle_service_event( + AgentServiceEvent::Run { + session_id: "s1".into(), + run_id: "run-1".into(), + event, + }, + cx, + ); + } + }, + cx, + ); + assert!( + (tops[0] - before).abs() <= px(1.), + "older rows hold still as the error replaces the dots: {before:?} then {tops:?}" + ); + assert_glides(&tops, "an older row"); + } + + #[gpui::test] + fn a_failed_send_reports_its_error_in_the_slots_place(cx: &mut TestAppContext) { + let clock = Clock::start(); + let (chat, cx) = pane(cx, 30); + chat.update(cx, |chat, _| { + let preview = sending::SendPreview::new(Some("s1".into()), "hello", &[]) + .capture_timeline(&chat.timeline); + chat.sending_previews.insert("send-1".into(), preview); + chat.awaiting_first_token = true; + }); + let (before, tops) = older_row_through_error( + &chat, + &clock, + |chat, cx| { + let backend = chat.backend.clone(); + chat.finish_send( + "send-1".into(), + UnresolvedSend { + backend, + outcome: None, + session: "s1".into(), + text: "hello".into(), + attachments: vec![], + from_composer: false, + }, + Err(UiFailure::Local( + "chat-upload-cancelled-unsent", + "Upload cancelled. No message was sent.", + )), + cx, + ); + }, + cx, + ); + assert!( + chat.read_with(cx, |chat, _| chat + .timeline + .iter() + .any(|row| row.item_type == "error")), + "the failure shows as an error row" + ); + assert!( + (tops[0] - before).abs() <= px(1.), + "older rows hold still as the error replaces the bubble: {before:?} then {tops:?}" + ); + assert_glides(&tops, "an older row"); + } + #[gpui::test] fn the_real_row_takes_over_from_the_bubble_without_a_jump(cx: &mut TestAppContext) { let clock = Clock::start(); diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index 7aacc8b58..c4618aa89 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -104,13 +104,18 @@ appear only after 300 ms, so fast loads never flash a spinner. when it settles: the sidebar collapse clips its column only while it moves, which forces a fresh paint each frame and a full-opacity one at rest. +- Covers and fades never block input. `swap_in`'s cover paints without a + hitbox, and a fading transcript or an entering card takes clicks and keys + from its first frame. Do not add a hit-blocking cover to hide a change. - Menus open from their trigger: the panel's fade and slide are drawn inside the deferred draw, because opacity does not reach into `deferred()`. A menu the keyboard opens appears at once, and keyboard highlight moves inside a menu settle at once; pointer openings and hover ease. - Transcript rows sit in a `gpui::list`, whose items lose element state when they scroll away. Their entrance is driven by the arrival time kept in view - state, so a row animates once and never replays. + state, so a row animates once and never replays. A row that replaces the + slot under the transcript (the waiting dots, a sent message, a failed + send) opens from the slot's height, so nothing above it jumps. - Every animated frame re-renders the window. Keep animations short, request frames only while something moves, and never animate inside cached views without an element id that is stable across frames. From 323e43d585de4397801ffd1bdf30ec7acbfe9a11 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:16:52 -0800 Subject: [PATCH 26/29] Agent: the custom amount field unfolds and folds back Choosing Custom amount under Buy credits dropped its field in at once and pushed the pay buttons down; choosing a package snapped them back. Add motion::unfold, which eases a box between nothing and its content's height over PANEL while the content fades, turns back from the height on screen, settles under reduced motion and draws nothing once folded, and use it for the field. Its top padding stands in for the card's gap, which would otherwise jump on its own. --- apps/maple-agent/app/src/ui/motion.rs | 206 ++++++++++++++++++ .../app/src/ui/settings/api_credits.rs | 74 ++++--- apps/maple-agent/docs/motion.md | 5 + 3 files changed, 254 insertions(+), 31 deletions(-) diff --git a/apps/maple-agent/app/src/ui/motion.rs b/apps/maple-agent/app/src/ui/motion.rs index 00acd20ad..bac31d401 100644 --- a/apps/maple-agent/app/src/ui/motion.rs +++ b/apps/maple-agent/app/src/ui/motion.rs @@ -733,6 +733,135 @@ pub fn chevron(id: impl Into, open: bool, size: Pixels, color: u32) - ) } +/// Content that opens in place under what sits above it, such as the field +/// a choice reveals: its box eases between nothing and the content's height +/// over [`PANEL`] while the content fades with it, so what sits below +/// glides instead of jumping. It folds away the same way, turns back from +/// the height on screen, and settles at once under reduced motion. Nothing +/// animates on mount. Keep passing the content while it folds; once folded +/// it draws nothing. `id` must be stable across frames. +pub fn unfold(id: impl Into, open: bool, content: impl IntoElement) -> Unfold { + Unfold { + id: id.into(), + open, + content: Some(content.into_any_element()), + } +} + +pub struct Unfold { + id: ElementId, + open: bool, + content: Option, +} + +// How far open the content is, and its height when last laid out. +struct Unfolded { + open: Tween, + natural: std::rc::Rc>>, +} + +impl IntoElement for Unfold { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl Element for Unfold { + type RequestLayoutState = AnyElement; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, AnyElement) { + let now = now(); + let reduced = cx.reduce_motion(); + let target = if self.open { 1. } else { 0. }; + let (shown, moving, natural) = window.with_element_state( + id.expect("unfolding content has an id"), + |state: Option, _| { + let mut state = state.unwrap_or_else(|| Unfolded { + open: Tween::new(target), + natural: Default::default(), + }); + let (shown, moving) = state.open.step(target, PANEL, ease_out_cubic, now, reduced); + ((shown, moving, state.natural.clone()), state) + }, + ); + if moving { + window.request_animation_frame(); + } + let content = self + .content + .take() + .expect("unfolding content is laid out once"); + let mut element = if shown <= 0. && !moving { + // Folded: nothing to draw or measure. + gpui::div().into_any_element() + } else { + let measured = natural.clone(); + let frame = gpui::div() + .relative() + .on_children_prepainted(move |bounds, _, _| { + if let Some(body) = bounds.first() { + measured.set(Some(body.size.height)); + } + }); + let body = gpui::div().child(content); + if moving { + // Laid out at its full height above a box that clips it, so + // its height is known from the first frame it shows. + frame + .h(natural.get().unwrap_or_default() * shown) + .overflow_hidden() + .child(body.absolute().top_0().left_0().right_0().opacity(shown)) + .into_any_element() + } else { + frame.child(body).into_any_element() + } + }; + (element.request_layout(window, cx), element) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + window: &mut Window, + cx: &mut App, + ) { + element.prepaint(window, cx); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + element: &mut AnyElement, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + element.paint(window, cx); + } +} + /// A sine pulse in `[low, high]` offset by `shift` cycles, for dots that /// breathe out of step with each other. pub fn pulse(phase: f32, shift: f32, low: f32, high: f32) -> f32 { @@ -1559,4 +1688,81 @@ mod primitive_tests { assert_eq!(moved.size, shifted.size); assert_eq!(cx.debug_bounds("below").unwrap(), below); } + + struct UnfoldHost { + open: bool, + } + + impl gpui::Render for UnfoldHost { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .child(unfold( + "unfold", + self.open, + div().debug_selector(|| "unfolded".into()).h(px(60.)), + )) + .child(div().debug_selector(|| "after".into()).h(px(20.))) + } + } + + #[gpui::test] + fn unfolding_content_pushes_what_follows_and_folds_back_from_the_screen( + cx: &mut gpui::TestAppContext, + ) { + let start = Instant::now(); + set_test_now(Some(start)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (host, cx) = cx.add_window_view(|_, _| UnfoldHost { open: false }); + cx.run_until_parked(); + let top = |cx: &mut VisualTestContext| cx.debug_bounds("after").unwrap().origin.y; + let rest = top(cx); + assert!( + cx.debug_bounds("unfolded").is_none(), + "folded, it draws nothing" + ); + + let draw = |at: Instant, open: bool, cx: &mut VisualTestContext| { + set_test_now(Some(at)); + host.update(cx, |host, cx| { + host.open = open; + cx.notify(); + }); + cx.run_until_parked(); + }; + draw(start, true, cx); + assert_eq!(top(cx), rest, "it opens from nothing"); + draw(start + PANEL / 2, true, cx); + let halfway = top(cx) - rest; + assert!( + halfway > px(30.) && halfway < px(60.), + "eases out: {halfway:?}" + ); + draw(start + PANEL / 2, false, cx); + assert_eq!( + top(cx) - rest, + halfway, + "folding starts from the height on screen" + ); + draw(start + PANEL * 2, false, cx); + assert_eq!(top(cx), rest); + assert!(cx.debug_bounds("unfolded").is_none()); + // The frame requested while it moved still runs once. + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + assert_eq!( + cx.update(|window, cx| window.simulate_next_frame(cx)), + 0, + "folded content asks for no frames" + ); + + cx.update(|_, cx| cx.set_reduce_motion(true)); + draw(start + PANEL * 2, true, cx); + assert_eq!(top(cx) - rest, px(60.), "reduced motion opens at once"); + set_test_now(None); + } } diff --git a/apps/maple-agent/app/src/ui/settings/api_credits.rs b/apps/maple-agent/app/src/ui/settings/api_credits.rs index 697156a37..6855fff45 100644 --- a/apps/maple-agent/app/src/ui/settings/api_credits.rs +++ b/apps/maple-agent/app/src/ui/settings/api_credits.rs @@ -415,6 +415,42 @@ impl SettingsScreen { cx, ), )); + let custom = credits.choice == CreditChoice::Custom; + let hint = match &credits.custom_credits { + None => None, + Some(Ok(amount)) => Some(( + self.messages + .get( + "settings-credits-cost", + [ + ("credits", (format_count(*amount as i64)).to_string().into()), + ("dollars", (amount / CREDITS_PER_DOLLAR).to_string().into()), + ], + ) + .to_string(), + theme::text_secondary(), + )), + Some(Err(error)) => Some(( + credit_error(*error).resolve().to_string(), + theme::status_error(), + )), + }; + // The custom amount's field unfolds under the packages when it is + // chosen and folds away when another package is, so the buttons + // below glide rather than jump. Its top padding stands in for the + // card's gap, which would otherwise jump on its own. + let custom_field = div() + .pt_3() + .flex() + .flex_col() + .gap_1() + .child(self.application_target( + target(CreditsTarget::CustomAmount), + widgets::input_frame().child(credits.custom.clone()), + )) + .when_some(hint, |column, (hint, color)| { + column.child(div().text_xs().text_color(gpui::rgb(color)).child(hint)) + }); let mut purchase = widgets::card_row() .flex() .flex_col() @@ -426,41 +462,17 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_primary())) .child(tr("settings-api-credits-buy-credits")), ) - .child(packages); - if credits.choice == CreditChoice::Custom { - let hint = match &credits.custom_credits { - None => None, - Some(Ok(amount)) => Some(( - self.messages - .get( - "settings-credits-cost", - [ - ("credits", (format_count(*amount as i64)).to_string().into()), - ("dollars", (amount / CREDITS_PER_DOLLAR).to_string().into()), - ], - ) - .to_string(), - theme::text_secondary(), - )), - Some(Err(error)) => Some(( - credit_error(*error).resolve().to_string(), - theme::status_error(), - )), - }; - purchase = purchase.child( + .child( div() .flex() .flex_col() - .gap_1() - .child(self.application_target( - target(CreditsTarget::CustomAmount), - widgets::input_frame().child(credits.custom.clone()), - )) - .when_some(hint, |column, (hint, color)| { - column.child(div().text_xs().text_color(gpui::rgb(color)).child(hint)) - }), + .child(packages) + .child(crate::ui::motion::unfold( + "credits-custom-amount", + custom, + custom_field, + )), ); - } let ready = credits.purchasing.is_none() && credits.selected_credits().is_ok(); let guest = self.guest_account(); diff --git a/apps/maple-agent/docs/motion.md b/apps/maple-agent/docs/motion.md index c4618aa89..fae1b5cf0 100644 --- a/apps/maple-agent/docs/motion.md +++ b/apps/maple-agent/docs/motion.md @@ -72,6 +72,7 @@ shared 15 fps `ticker` clock and pause in background windows. | Files attached to the draft | fade and 6 px rise, `ENTER` | immediate | | Composer and multi-line fields | height eases to the new line count, `resize_duration` | same | | Composer expand / shrink | height eases, `PANEL`; maximize and minimize icons cross-fade and scale, `TOGGLE` | same, reversed | +| A field a choice reveals (Buy credits → Custom amount) | box unfolds to the field's height and the field fades in, `PANEL` | folds the same way, reversed | | Send ↔ Stop | icons cross-fade and scale, `TOGGLE` | same | | Switches and checkboxes | knob slides, fill cross-fades, `TOGGLE` | same, reversed | | Disclosure chevrons | rotate 90°, `TOGGLE` | same, reversed | @@ -90,6 +91,10 @@ appear only after 300 ms, so fast loads never flash a spinner. - `animated(id, target, ...)` keeps a `Tween` in element state for render code that owns no state (chevrons, switches, icon swaps). It never animates on mount. +- `unfold(id, open, content)` opens content in place: its box eases between + nothing and the content's height while the content fades, so what follows + glides, and it folds away the same way. Use it for in-flow content a + choice shows or hides, not for menus or dialogs, which close at once. - `fade_in`, `rise_in` and `slide_in` are one-shot entrances keyed by element id; a new id replays them. `swap_in(element, id, key, cover)` fades content in whenever `key` changes but draws its first appearance at once, for From 12b6cd58b585d01df96fea81b976fa920f57fb8d Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:12:42 -0800 Subject: [PATCH 27/29] Agent: the empty state's heading rides with the composer instead of being cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shrinking the expanded composer from the empty state grew the heading's folding box from nothing with its text pinned to the box's top and clipped, so "Work on anything…" showed sliced against the composer's edge for most of the transition. Fold the box as before but leave its content whole: the heading sits at the box's foot and the note at its head, both fading, so the text rides with the composer at its resting gap. The content keeps recording its height, so a fold that starts before the heading was ever measured catches up on the next frame. --- apps/maple-agent/app/src/ui/chat/composer.rs | 13 +++++ apps/maple-agent/app/src/ui/chat/mod.rs | 50 +++++++++++++------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index cf7ada241..e93ee1519 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -2067,6 +2067,19 @@ mod resize_tests { motion::set_test_now(Some(shrink)); toggle(&chat, cx); assert_eq!(cx.debug_bounds("composer-box"), Some(expanded)); + // On the way back the heading rides above the composer at its full + // size: the folding box never cuts it against the composer's edge. + for step in 1..8 { + frame_at(shrink + motion::PANEL * step / 8, cx); + let composer = cx.debug_bounds("composer-box").expect("composer"); + let title = cx + .debug_bounds("empty-title") + .expect("the heading draws on the way back"); + assert!( + title.bottom() <= composer.top(), + "the heading stays clear of the composer: {title:?} {composer:?}" + ); + } frame_at(shrink + motion::PANEL - Duration::from_millis(1), cx); let landing = cx.debug_bounds("composer-box").expect("composer"); frame_at(shrink + motion::PANEL, cx); diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 689013983..9238f5e14 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -7973,27 +7973,43 @@ impl ChatScreen { None => 1., }; let measured = self.composer_frame.get(); - // At rest a block records its height; on the way it is drawn at - // `hero` of that height and opacity. Each block carries the - // spacing beside it as its content's margin (padding would not - // fold), so nothing is left behind. - let fold = |block: Div, rest: Option, record: composer::Record| { - if composer_resize.is_some() { - block - .flex_none() - .h(rest.unwrap_or_default() * hero) - .overflow_hidden() - .opacity(hero) - } else { - block + // At rest a block records its height. On the way it is drawn at + // `hero` of that height while its content keeps its full size, held + // against the composer (the heading at the block's foot, the note at + // its head) and fading, never clipped: text rides with the composer + // instead of being cut by the folding box. The content keeps + // recording its height, so a block first drawn mid-fold folds from + // the next frame. Each block carries the spacing beside it as its + // content's margin (padding would not fold), so nothing is left + // behind. + let fold = + |content: Div, rest: Option, foot: bool, record: composer::Record| { + let content = content .relative() - .child(composer::measure(&self.composer_frame, record)) - } - }; + .child(composer::measure(&self.composer_frame, record)); + if composer_resize.is_some() { + // Squared, so text that has slid past the pane's edge is + // already faint. + let content = content.absolute().left_0().right_0().opacity(hero * hero); + div() + .relative() + .w_full() + .flex_none() + .h(rest.unwrap_or_default() * hero) + .child(if foot { + content.bottom_0() + } else { + content.top_0() + }) + } else { + content + } + }; let title = (hero > 0.).then(|| { fold( div().w_full().flex().flex_col().items_center().child( div() + .debug_selector(|| "empty-title".to_string()) // Its own space and the gap to the composer. .mb_12() .font_family(crate::assets::FONT_DISPLAY) @@ -8003,6 +8019,7 @@ impl ChatScreen { .child(tr("chat-empty-title")), ), measured.title, + true, |frame, bounds, _| frame.title = Some(bounds.size.height), ) }); @@ -8020,6 +8037,7 @@ impl ChatScreen { .child(tr("chat-empty-private")), ), measured.privacy, + false, |frame, bounds, _| frame.privacy = Some(bounds.size.height), ) }); From ad8f7081b46df005e0fcb30014257c2a6ad116b0 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:01:38 -0800 Subject: [PATCH 28/29] Agent: whatever comes with the composer stretches while it shrinks back The region under the composer eases its height while the composer shrinks back from expanded, but only the composer's own box stretched to fill it. Anything render_composer wraps the box in, such as a new task's folder picker by the prompt (#1353), stretched only while the composer was expanded, so on the first frame of shrinking the box snapped to rest and the heading and the box dropped. Stretch whatever render_composer returns for as long as the composer fills, expanded or on the way there and back, and test a new task's shrink from where it was. --- apps/maple-agent/app/src/ui/chat/composer.rs | 53 ++++++++++++++++++++ apps/maple-agent/app/src/ui/chat/mod.rs | 16 +++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index e93ee1519..2795b37b2 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -2091,6 +2091,59 @@ mod resize_tests { motion::set_test_now(None); } + /// A new task has no session yet. Shrinking its expanded composer starts + /// from the expanded box and eases down to rest, so nothing around it + /// jumps, whatever the composer brings along for a new task. + #[gpui::test] + fn a_new_task_shrinks_its_composer_from_where_it_was(cx: &mut TestAppContext) { + let start = Instant::now(); + motion::set_test_now(Some(start)); + cx.update(|cx| cx.set_reduce_motion(false)); + let (chat, cx) = chat_window(cx, 0); + chat.update(cx, |chat, cx| { + chat.selected_session = None; + cx.notify(); + }); + cx.run_until_parked(); + let rest = cx.debug_bounds("composer-box").expect("composer"); + toggle(&chat, cx); + frame_at(start + motion::PANEL * 2, cx); + let expanded = cx.debug_bounds("composer-box").expect("composer"); + assert!( + expanded.size.height > rest.size.height, + "{rest:?} {expanded:?}" + ); + + let shrink = start + motion::PANEL * 3; + motion::set_test_now(Some(shrink)); + toggle(&chat, cx); + assert_eq!( + cx.debug_bounds("composer-box"), + Some(expanded), + "shrinking starts from the expanded box" + ); + let mut heights = vec![expanded.size.height]; + for step in 1..8 { + frame_at(shrink + motion::PANEL * step / 8, cx); + heights.push(composer_height(cx)); + } + assert!( + heights.windows(2).all(|pair| pair[1] <= pair[0]), + "it only shrinks: {heights:?}" + ); + assert!( + heights[1] > rest.size.height, + "it eases rather than snapping to rest: {heights:?}" + ); + frame_at(shrink + motion::PANEL * 2, cx); + assert_eq!( + cx.debug_bounds("composer-box"), + Some(rest), + "it lands at rest" + ); + motion::set_test_now(None); + } + /// The first message carries the composer from the empty state down to /// the foot of the conversation: it starts where it was drawn, moves /// only down, lands exactly at rest, and then asks for no frames. New diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 9238f5e14..d92d55837 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -7319,7 +7319,12 @@ impl Render for ChatScreen { .children(self.render_plan_card(cx).map(|card| { motion::slide_in(card, "plan-card-enter", motion::ENTER, rise) })) - .child(self.render_composer(window, cx)) + .child( + self.render_composer(window, cx) + .when(self.composer_fills(), |composer| { + composer.flex_1().min_h_0() + }), + ) .children(self.render_slash_palette(cx)) .children(self.render_persistence_failure(cx)) .child(composer::measure( @@ -8081,7 +8086,14 @@ impl ChatScreen { None if expanded => wrap.flex_1().min_h_0().flex().flex_col(), None => wrap, }) - .child(self.render_composer(window, cx)) + // Whatever comes with the composer (a folder + // picker by the prompt, say) fills the region + // with it while it fills: expanded, and on the + // way there and back, not only once expanded. + .child( + self.render_composer(window, cx) + .when(fills, |composer| composer.flex_1().min_h_0()), + ) .children(self.render_slash_palette(cx)) .child(composer::measure( &self.composer_frame, From f850a0801ca7993b07d03a6df77b44190b4822c5 Mon Sep 17 00:00:00 2001 From: Matthew Ball <13823657+matthewrball@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:02:16 -0800 Subject: [PATCH 29/29] Agent: the empty-state pane clips while the composer resizes The folding heading is held against the composer and rides above its shrinking box, so in a short or narrow window it reached past the pane's top edge late in the fold and drew faintly over the title bar and window controls. The pane now clips while the composer moves; at rest nothing is clipped, so the slash palette and the glide are untouched. --- apps/maple-agent/app/src/ui/chat/mod.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index d92d55837..6d33d9f94 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -7993,8 +7993,8 @@ impl ChatScreen { .relative() .child(composer::measure(&self.composer_frame, record)); if composer_resize.is_some() { - // Squared, so text that has slid past the pane's edge is - // already faint. + // Squared, so text nearing the pane's edge, where the + // pane clips it, is already faint. let content = content.absolute().left_0().right_0().opacity(hero * hero); div() .relative() @@ -8056,6 +8056,10 @@ impl ChatScreen { .justify_center() .px_6() .when(fills, |pane| pane.py_6()) + // The folding heading rides above its shrinking box; in a short + // pane it would reach past the pane's top edge and draw faintly + // over the title bar, so the pane clips while the composer moves. + .when(composer_resize.is_some(), |pane| pane.overflow_hidden()) // Expanded, the composer region takes the pane less its padding. .child(composer::measure( &self.composer_frame,