Agent pane: the subagent tray moves with the transcript (eases in like a message, leaves on a snapshot, folds, ticks)

Merged
TE
8d ago
+1241-21

Leo, 2026-10-26: "When I said to animate this stuff in line with everything else, like when you open, when you close, this too, something running, whatever, this more slightly more dynamic. I don't even know. Like why is this not animating and, like, following along with the exact same things as everything else? This section right here, it just doesn't conform to the animating sort of train. Like most of these are just asinine decisions. ... how could you even ever sign off on something like this? You can't."

The subagent tray above the composer (#25144's pinned header and View all, unchanged) was a static bar: it snapped in and out, its list jumped open and shut, and its count jumped. Now it moves on the same tokens as a new message (cv-enter on --agent-motion-in / --agent-ease-out, conversation.css):

  • In: when the first subagent, command or started chat appears in a chat that showed without one, the card fades and rises 4px exactly like a live transcript row. A chat that opens or comes back with work running shows the tray in place (no entrance on a switch).
  • Out: when the last line goes, a kept copy of the card (inert, aria-hidden, absolutely placed at the card's spot over the stack) fades and sinks on --agent-motion-out. The real card is already gone, so the stack takes its new height at once and nothing waits on the motion. A chat switch drops any copy right away. The copy keeps the card's drawn state: a mid-entrance card leaves from where it was drawn, a scrolled list or command output keeps its scroll offsets, and a fold mid-open or mid-close keeps its drawn height and fade. A move to another placement drops the copy at once, the same as a chat switch.
  • Open/close: the list and View all sit in the shared fold (useFold, conversation/fold.ts, --cv-fold-duration): height only on open (no fade-in), fade on close. One Web Animation per toggle, no JS per frame.
  • Lines joining the open list rise in (data-enter="bottom"). Lines it was drawn with, and lines a fold or View all brings back, are simply there. A burst of more than 3 new lines at once simply appears. Arrivals are worked out only when the tray's item list changes, never per clock tick.
  • Count/state tick: View all's count, the header's "2 subagents running" → "3 subagents running" and a line's Running → Waiting/Done cross-fade in one cell (Tick): the old words leave up and the new ones rise in. The old text is aria-hidden.
  • Spinner: the header's icon and every running subagent, chat or command line turn <Spinner loading />. No dot.
  • Reduce Motion / data-agent-motion="off" / hidden page: every change applies at once, with no entrance and no copy. The entrance, the fold and arrivals all go through the shared page-reveal gate (entranceAllowed, shared/pageReveal.ts), so a hidden page and its first frames back play nothing. If Reduce Motion is switched on mid-animation, a matchMedia change listener finishes whatever is playing. animationcancel (motion switched off midway) settles ghosts, entrance marks and ticks.
  • No menus or popovers are added. The tray's hover cards keep their open-at-once behaviour.

Motion token: no new token. The tray uses the new-message entrance's own: cv-enter keyframes on --agent-motion-in + --agent-ease-out, with --agent-motion-out for the exit. I told big-red/nx-chat (which owns the message entrance, m1110) the tray follows those names, so retuning the entrance retunes the tray.

Gallery (agent-pane.subagent-tray-motion, view Compare arms, experiment subagent-tray-motion, trayMotion.experiment.ts): three arms. Only message ships. The tight and spring CSS lives in gallery/fixtures/trayMotionArms.css, which only the gallery stage imports.

  1. message (shipped): the message's own rise. In the recordings it is the one that reads as "the same train" as the row arriving beside it.
  2. tight: opacity only, 120 ms in and 80 ms out.
  3. spring: an 8px rise from 98% scale over 180 ms with a small overshoot. It reads as a different system from the transcript, so it was not picked.

Recordings (gallery, the card next to a message row arriving):

DarkLight
Before (static bar)before
After, message (shipped)after darkafter light
Arm tight (gallery only)tight
Arm spring (gallery only)spring
StripDarkLight
Beforestrip before
Afterstrip after darkstrip after light

Timers, observers and listeners (each has a stop and a cleanup):

  • Exit copy (playTrayExit): animationend + animationcancel listeners and one fallback setTimeout(ms + 50). Any of them (or settleTrayExit on a card coming back or another chat showing) removes the copy, both listeners and the timer. At most one copy per tray host.
  • Entrance mark (useTrayMotion): animationend + animationcancel on the card. Removed on end, on cancel, and in the ref cleanup.
  • Tick: animationend + animationcancel on the entering and the leaving span, added and removed by their refs.
  • Reduce Motion (watchReducedMotion in trayMotion.tsx): one matchMedia("(prefers-reduced-motion: reduce)") change listener per card. When it fires, it finishes the card's running animations and settles its entrance and copies. It is removed in the card's ref cleanup.
  • Arrivals (SubagentTray.tsx): one animationend + animationcancel pair per arriving line, added by that line's stable ref. It is removed when the line's rise ends or cancels, or when the line detaches.
  • Page reveal: the shared entranceAllowed gate (shared/pageReveal.ts), with its existing visibilitychange listener and reveal frames. No new instance.
  • useFold: unchanged shared code (its MutationObserver and ResizeObserver live only while a fold plays).
  • App.tsx: one useLayoutEffect keyed on the shown chat that settles leftover copies. No new per-token or per-event O(rows) work: the tray's own lists are bounded and only compute on the tray's render.

Risk: low to medium. This is UI motion on the tray only. The DOM changes in one way: the list and View all now sit inside .acpmux-running-tray__fold. bottomStack.css's short-pane :has() rule and the tray structure test follow that. The bottom stack's rise-on-mount no longer plays on a chat switch for the tray (it plays only through data-tray-enter).

Tests (run on the fleet through cmux-ci test): fmt, lint and tsc are clean. New: test/subagent-tray-motion.test.tsx (11; it adds a tray leaving mid-fold). It covers: entrance only when asked; the kept copy's placement, inertness and scroll offsets; a copy dropped on return, on a chat switch and on animationend; a tick that keeps and then drops the old words; no motion = instant; a placement change; a hidden page and its first frames back; a same-content remount and re-entry; a resize mid-enter and mid-exit; animationcancel cleanup; and a Reduce Motion change mid-animation. fixed-waits, layers and type-scale ratchets: ok. Passing: subagent-tray (30), subagent-corner-dots (3), motion-scale (5), running-strip (7), agent-pane-bottom-stack (8), and inspector-dead-clicks (6, plus its base red). Base reds, which fail the same way on base 394e5ca: gallery-coverage (2), running-wave Inspector, agent-pane-working-counts (2), agent-pane-dock-gutters (3), inspector-dead-clicks .cv-subagent-avatar, font-shorthand (gallery CSS this PR doesn't touch), gallery health (composer and new-tab play states).

Review: Codex gpt-6-astra (high, fast) returned SHIP at 65f6554f24ea773fff4450fff8e59abc07607c6f, after 15 rounds of fixes.

Size: about 1,200 added lines, which is over the ~800 guide. About 400 of them are tests and about 250 are gallery-only code (the stage, the entry and the two unshipped arms' CSS). The shipped runtime change is about 550 lines and is one behaviour, so it stays one PR.

Agent session: agent session

šŸ¤– Generated with Claude Code

Real-app capture (after merge)

From a fleet Mac build at f0b80db4, which adds the debug subagents seed fixture from #26787. It runs the capture state subagent-tray.json: two subagents start and the tray eases in, the tray folds shut and open, a message arrives, each subagent ends and the count ticks, and the group goes and the tray leaves.

LightDark
lightdark

video
Full quality: