镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Agent: add purposeful motion across chat, composer, menus and settings - #1515

Open
matthewrball wants to merge 30 commits into
masterfrom
ux/motion-polish
Open

matthewrball wants to merge 30 commits into
masterfrom
ux/motion-polish

Conversation

@matthewrball

@matthewrball matthewrball commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Current review stack

Standalone on master; not part of a stack.

Motion across chat, the composer, menus and Settings. It was held until last, since it overlaps most of this week's merged work.

Refreshed onto master 6e981a88 by merging master in; no commits were rewritten. The motion now sits on the current UI:

Local checks on this head: app suite 1,470 passed, 0 failed, 3 ignored; cargo fmt --check and the three clippy lanes pass on Rust 1.98. The recordings below predate this week's merges: they show the older composer (with the Web chip) and the earlier layout.

Current head: a587cdaf.

The original scope and validation are retained below.


Moved from #1509 so the branch lives in this repo. Same commits; earlier discussion is on #1509.

Why

Maple Work changes state in a single frame almost everywhere. Menus pop, dialogs appear, the composer jumps a line at a time, new messages land without any sense of where they came from, and the whole thread jumps when a tool card arrives. This adds short, purposeful motion across the app so each change reads as cause and effect, without slowing anything down.

Every animation is 200 ms or less, eases out, and settles at once under Settings → General → Reduce motion. Transitions that can reverse (hover, switches, chevrons, the composer, the sidebar) turn back from where they are when interrupted; entrances play once. Send, Stop, Escape and keyboard shortcuts act on the frame they arrive. Streaming text, typing and keyboard navigation in menus and lists don't animate, and menus and dialogs close immediately.

The shared rules and tokens are in apps/maple-agent/docs/motion.md, and ui/motion.rs implements them. This carries #1355 (hover transitions) forward onto current master, so #1355 can close when this lands.

What this PR adds

Conversation

  • Messages arrive instead of appearing. A sent message, the reply, thinking, tool cards, errors and the stopped notice grow into place at the end of the thread (height and opacity, 180 ms), so older messages glide up instead of jumping. History, task switches, paging and reconnects stay still. Rows never replay when scrolled back, and streaming text grows in place.
  • The sent message lands where it was. The bubble rises in as it leaves the composer and the real row takes its exact place. "Sending…" and the waiting dots now share one slot under it, so nothing jumps when the reply starts. A run error or a failed send takes that slot's place the same way.
  • The composer travels with the conversation. On the first message, opening a task from the empty state, or New Task, the composer glides between the centred hero and the foot of the thread (200 ms) instead of jumping.
  • Tool status swaps from the spinner to its outcome with a short fade. Disclosure chevrons turn, and opened details fade in.
  • Switching tasks fades the new transcript in when it lands. The header spinner waits 300 ms, so fast opens never flash it.
  • Waiting dots fade in. Notices, queued messages, questions and the plan, btw and subagent cards above the composer rise in.

Composer and text fields

  • Grows and shrinks with the text. Each added or removed line eases the box over 120–200 ms, scaled by distance, and turns back mid-way if you keep typing. Window resizes, the sidebar sliding and font changes snap. Settings → Additional instructions and the feedback form get the same behaviour.
  • Expand and shrink ease the composer's height over 200 ms and reverse from mid-flight. The transcript stays pinned to its latest message, and the maximize and minimize icons cross-fade. From the empty state, the title and privacy line fade as they ride with the composer, keeping their resting gap and never cut by their folding space.
  • Send ↔ Stop cross-fade, and attached files settle in.
  • Custom amount unfolds. Choosing Custom amount under Buy credits opens its field in place over 200 ms while it fades in, and choosing a package folds it away, so the pay buttons glide instead of jumping. It turns back mid-way, and motion::unfold makes the same reveal available to any field a choice shows.

Menus, dialogs and navigation

  • Menus and popovers opened with the pointer open from their trigger: a fade and a 4 px slide away from it (down below the trigger, up above it, sideways for submenus), 140 ms. Menus opened from the keyboard appear at once, and arrow keys move the highlight at once; pointer hover eases.
  • Tooltips fade in after the existing delay.
  • Dialogs (confirm, trust, feedback, project folder, update): the scrim fades and the card rises 8 px over 180 ms.
  • Chat ↔ Settings, Login and Plan screens, and Settings sections fade in over 140 ms. The first screen at launch draws at once.

Controls

Also fixed

  • Multi-line fields measured their rows with the window's default line height (26 px) but drew them in the chat reading face (23 px), leaving extra space under the composer text. They now measure with the style they paint in, so the height animation lands exactly on the text. The resting composer is a few pixels shorter.
  • A run error used to skip the path every other live row takes, so the waiting dots stayed under it until the run ended. It now goes through that path: the dots leave when the error lands, and a thinking block just above the error gets its summary like it would before any other row.

Preview

Request → progress → result → revision First message from the empty state
Sending a message: the bubble rises in, older messages glide up as tool cards and the reply grow in, then a revision New Task, then the first message carries the composer from the hero down to the foot of the thread
Composer grows, shrinks, expands Menus, Settings and sections
The composer grows line by line, shrinks, then expands and shrinks with the icon cross-fading Model and Settings menus open from their triggers; Settings and its sections fade in; Additional instructions grows; back to chat
Interrupted (Stop) Error
Stop during a tool run: the tool fails and Stopped by user appears A run that ends in the Out of usage card
Opening another task (loading) Switch reversed mid-flight
Selecting another task: the spinner waits, then the transcript fades in The web switch toggled off and on, then twice quickly, reversing from where the knob is
Expand and shrink from the empty state
From the empty state the composer expands as the heading and privacy line fade away, then shrinks back as they fade in at their resting gap

Recorded window-only from a local-only preview build of this branch, with sample tasks and a scripted run (no account or backend). The preview window stayed in the background the whole time, where it drew at about 30 fps, so motion looks slightly steppier here than in the focused app. The preview hook is not committed.

Validation

Run on macOS 26 with the Homebrew Rust 1.96.0 toolchain, at ad8f7081:

  • cargo test -p maple-agent-app --locked: 1,378 passed, 0 failed, 3 ignored.

  • cargo clippy -p maple-agent-app -- -D warnings for every feature set CI checks: desktop (--all-targets), headless (--all-targets --no-default-features --features acp,proxy), acp only and proxy only. All clean. With this local toolchain, two lints already fire on master in files this PR doesn't touch (unexpected_cfgs in the Objective-C macros, clippy::nonminimal_bool), so they were allowed with -A; CI's pinned toolchain runs the real gate.

  • cargo fmt --all --check: clean.

  • GitHub CI on ad8f7081: agent-lint, agent-test, agent-macos and agent-windows pass.

  • New tests pin the motion clock (motion::set_test_now). They cover:

    • transitions turning back from the value on screen and then asking for no more frames (animated values, hover, the switch knob);
    • paint offsets that never move neighbouring rows;
    • live rows growing in once, while history, task switches, paging, reconnects and streaming appends stay still;
    • the sent message handing off without a jump, with older rows only ever moving up;
    • text fields easing to a new line count and snapping on width changes and under reduced motion;
    • the composer reversing expand mid-flight and gliding between the empty state and the thread;
    • menus sliding from the correct side, and only when opened with the pointer;
    • dialogs leaving on the frame they close;
    • Settings → Back bringing the sidebar's task rows back;
    • the first send in a new task opening its slot, and a run error or a failed send taking the slot's place, instead of jumping (each failed before its fix);
    • unfolding content opening from nothing, folding back from the height on screen, drawing nothing once folded, and opening at once under reduced motion;
    • the empty state's heading staying clear of the composer while the composer shrinks back (failed before its fix);
    • a new task's composer shrinking back from the expanded box, not snapping to rest (failed on the PR stack with Agent: pick a new task's folder by the prompt and clear the task top bar #1517's folder picker before its fix).
  • Visual: I drove every scenario in the GIFs through a local-only preview build of this branch, using sample tasks and a scripted run with no account or backend, and reviewed them frame by frame. That review caught three problems, fixed here before opening the PR:

    • the whole thread jumped when a message was sent;
    • the tool status flashed garbled double text as it changed;
    • the sidebar stayed transparent after Settings → Back. gpui replays a cached view's last paint, opacity included, so the screen and Settings fades now paint a fading cover over the content instead of fading the content itself.
  • Review: an independent read-only review, then advisor passes from Codex and Grok 4.7 (Gemini was out of credits) with Claude cross-checking both, found no blockers. Fixed from their notes:

    • the first send's slot in a new task, which appeared at full height;
    • a run error or a failed send, which dropped the thread by the slot's height before the error grew in;
    • the question card, which popped in while the other cards above the composer rose in;
    • the copy button's hover plate, back to the translucent overlay it had on master;
    • a reconnect test that now drives the real replay path, three doc claims the code didn't back, and a doc note that covers and fades never block input.

    Left as is: a growing field reveals its newest line as the box grows rather than scrolling it into view first; a card that appears above the composer still takes its space at once.

  • Hands-on testing in builds combined with other open PRs found two problems when shrinking the composer from the empty state:

    • "Work on anything…" was cut against the composer's edge. The heading now rides with the composer at its resting gap and fades instead; I checked this frame by frame in the preview.
    • With Agent: pick a new task's folder by the prompt and clear the task top bar #1517's folder picker by the prompt, the box snapped to rest on the first frame and the heading and box dropped. Whatever render_composer wraps the box in now stretches for as long as the composer fills, not only once it's expanded.

The repository pre-commit hook isn't installed in this clone (core.hooksPath is unset), so it didn't run on these commits. I ran the app crate's checks above by hand instead; the other workspace crates are unchanged.

Not verified:

  • Windows and Linux rendering. The code is platform-neutral gpui, but I only ran it on macOS.
  • A signed-in account against a live backend. The runs in the GIFs feed scripted events through the real timeline code.
  • Frame pacing in a release build. The GIFs come from a debug-profile preview in a background window, which drew at about 30 fps.
  • On screen, the fixes made after review (the Custom amount unfold, errors taking the slot's place, the question card's entrance); the GIFs predate them. Tests cover the unfold and the error handoffs over time, not the question card.
  • VoiceOver.

Not in this PR

  • gpui can't scale or translate a div, so movement is a layout-free paint offset, and press feedback stays an immediate colour change rather than a scale.
  • Maple's own Reduce motion setting governs. The operating system's reduce-motion preference isn't read, because gpui doesn't expose it.
  • Menus and dialogs close without an exit animation on purpose: dismissal is the user's decision and the next action shouldn't wait for it.

Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Keep the waiting dots' spacing now that they fade in, drop the unused
Animated::enter_from, and document swap_in and the menu entrance.
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.
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.
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.
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.
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.
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.
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.
…ns 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.
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.
…eing cut

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.
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.
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.
Brings the motion branch up to master at 6e981a8 and re-applies its
motion onto master's current UI.

- widgets: default_button eases its hover like primary_button, and
  keeps master's external_mark and navigate_mark.
- Settings: the regrouped sidebar's rows ease their hover through
  render_nav_item, the pane still fades between sections, and the
  on/off pill eases to master's default-button colors.
- Composer: chip_frame carries the hover transition, so the model chip
  and the new project chip share it. The Web chip is gone on master, and
  its accent highlight with it.
- A new task's folder picker stretches with the composer for as long as
  it fills, in the container itself rather than at the two call sites.
- Question card: master's compact card keeps the turning chevron, the
  eased option rows and the rise on entry; its footer buttons take the
  animated button type.
- The runtime error under the empty state uses master's toned banner
  and keeps its top margin.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants