Repository navigation
fix(motion): Cmd-B and Cmd-D move every pane on one timeline - #36677
Conversation
…ether beside an agent chat Leo 2026-11-05: "everything moves as one". scripts/cmux-next/one-motion-trace.py (split-spring.sh --suite=one-motion) traces the sidebar, every pane's frame and laid-out content, terminals and pages on each display frame (debug.motion trace_*), optionally records each motion (record_*), and reports when each mover started and ended, the spreads between them, and content lag behind its pane's landing, with an SVG progress timeline per motion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
…ame frame (red) Leo 2026-11-05 (Cmd-B then Cmd-D): "the right pane shifts and then the main pane waits for the right pane to shift before it shifts". A viewport resize under a running split only retargets each spring, so the panes chase the sidebar's moving edge instead of riding it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
…one motion) The sidebar's slide resizes the screen on every frame. With a split still springing, each resize only moved the springs' targets, so the panes chased the moving edge a spring's lag behind it. Now each frame on a spring moves with its target (AnimatedFrame.rebase, an additive animation: the frame keeps what it had left to travel and its velocity), so the panes follow the slide in its frame and only the split's own travel springs. A resize at rest still snaps; a layout change (preset, equalize, split) still springs as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
…'s first frame (red) Leo 2026-11-05: "the main pane waits for the right pane to shift before it shifts. Like, why are you waiting?". An agent chat on a split, close or zoom spring still lays out once (Leo 2026-10-20: never per frame), but at its rest size on the first frame, pinned at the edge that stays, not at the landing; a retarget lays out once more at its new rest, as a terminal's grid does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
…first frame, not at the landing Leo 2026-11-05: "the main pane waits for the right pane to shift before it shifts. Like, why are you waiting?". An agent chat on a split, close or zoom spring kept its start width (the larger of start and rest) until the landing, so a shrinking chat reflowed only after the new pane had slid in. It now takes its rest size on the spring's first frame, pinned at the edge that stays, as a terminal hears its final grid at the start: still one layout per spring (Leo 2026-10-20), and a retarget lays out once more at its new rest. Nothing changes at the landing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
Leo 2026-11-05: "If we had a proper animation system, everything moves as one, as one." The sidebar slid on an AppKit animation timer of its own while the panes' springs step on the window's MotionClock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
…anes (one motion) Cmd-B slid the sidebar's width (and its clip's reach) through a constraint animator, an AppKit timer of its own, while the panes beside it step on the window's MotionClock. The two landed in different passes and drifted, worst with a split still springing (Cmd-B then Cmd-D). TimedProgress (CmuxNextDesign) is a timed transition stepped on MotionClock: the sidebar's width and clip ride one progress on it, so each slide frame and the springs' frame step together and show in one commit. Same tokens (appear/disappear durations), same ease-out curve (cmux-motion's cubic-bezier), Reduce Motion and the sidebar motion switch still land it in one frame, and a toggle mid-slide turns around from the width on screen. The fade leg (opacity) is unchanged. Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db Sidebar-Look: no visual change (same tokens and curve, stepped on the window's motion clock) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a 5000-row chat Untraced repeats of each motion (--frame-rounds, default 3) report missed frames, frame interval p95/max, main-thread work per frame and hangs (debug.frames, debug.main_cpu, debug.hangs), so the chat's one layout at a split's start is judged against today's on the same harness (a long transcript, --rows 5000). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
The rebase moved each frame with its target, and then settleCarried put every pane back at the x it showed before the pass: a resize reconcile ran with animated: true, which counted as an animated width change (M8) and carried. The new pane of a split then chased its moved target by the resize (ViewportResizeMotionTests: 20 pt left over after a 40 pt slide frame). A rebasing pass is not carried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
Under the rest-size-up-front rule (392b4cc): the takeover test now expects the key spring's rest once and the split's once, and nothing at the landing; the landing test's precondition is the rest size before the landing, not the start size; the shrink test compares the pin with the clip's leading edge (the 8 pt padding is in the clip's own frame). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
TimedProgress ran its completion in the step that applied the end, before that frame was committed, so the sidebar reported its slide settled while the compositor still showed the frame before: SidebarToggleSettlementTests (hide, no reversal) read a drawn width 2 pt off its target on its first settled sample, at 6cfc894. AppKit's animator ran the completion once the last frame showed; the end now waits one frame for its completion the same way. motionSettle (an end at once) still completes at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
SidebarToggleSettlementTests (startShown true, reverse true) failed at e30ae3c: when the reversal came after the slide had stepped in the same frame, the new leg started from the stepped constraint, one frame past the laid-out width on screen, so the sidebar went one frame further before it turned around. The new leg now starts from the laid-out width, with the clip's reach at the same point of the old leg. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db Sidebar-Look: no visual change (a slide that turns around turns from the frame on screen; same tokens and curve)
…screen at once SidebarToggleSettlementTests (startShown false, reverse true) still failed at 86e49d2: the old slide had stepped the constraints earlier in the frame, and the new leg started from the shown width but left them there, so that frame's layout drew one step past the turn. The new leg now sets them to its start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db Sidebar-Look: no visual change (a slide that turns around turns from the frame on screen; same tokens and curve)
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
Shipped in:
|
… as #26118 did The held chat now lays out once at its rest size on the spring's first frame (392b4cc). Its retarget path had also dropped #26118's rule ('retain held size across pane retargets', 'keep chat paint stable across pane interruptions'): a close interrupted by an early reopen reflowed the chat at each new target. A retarget now keeps the largest size this motion laid the chat out at, and a larger rest grows it once; the spring's start is unchanged. PaneMotionContentHoldTests: aRetargetToASmallerRestKeepsTheLargerPageUntilTheLanding (was aRetargetedSpringLaysOutOnceAtTheNewRest, which encoded the dropped rule). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
|
For awareness (
The suites around these, including the FRAME OWNER S1 and S3 suites, pass at the head. The numbers are in #36677. |
|
Review (codex, router-review): SHIP at 92c7522 Reviewer: codex gpt-6.1-sol xhigh, fast tier. Follow-ups: 1. They land as checklist items in this PR's one follow-up tracking issue in this repo ( SHIP at 92c7522 Visual verdict: no images; Leo tries the build.
Checked the focused Python tests and motion static check; both pass. Follow-ups:
|
…ro (red) Router review of #36677 at 92c7522 (F1): the slide kept writing the clip edge it started on after applySide moved the reach to the other edge. Sidebar-Look: no visual change (test only) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
…a side or reach change setClipReach (applySide, a style switch, a reach change in apply) now points a running slide's reach leg at the edge it set, at its new reach, so the slide's next frames never write the edge it started on. Sidebar-Look: no visual change (a side switch mid-slide ends with the same clip as one at rest) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
GitHub kept #36677's head at 92c7522 after 4808b49 was pushed (no pull_request event fired). No change to the tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-00bbcfe801cee26c35db
|
Review (codex, router-review): SHIP at 1e45fde Reviewer: codex gpt-6-astra high, fast tier. Follow-ups: 1. They land as checklist items in this PR's one follow-up tracking issue in this repo ( SHIP at 1e45fde Visual verdict: no images; Leo tries the build
Follow-ups:
|
…its rest as it starts (#37079) * test(layout): the retarget test reopens the close on its way, not at the new rest's place aRetargetToASmallerRestKeepsTheLargerPageUntilTheLanding has failed on every tip Swift suite run since #36677 (a8bcdff, first red at f1965d7, run 42089396624; the PR's own Swift suite was skipped): it retargeted the close back to 200 pt while the host still stood at 200 pt, its place and size at the new rest. A host there is at rest and holds nothing (contentFrame), so the chat laid out at 184 pt. An early reopen comes while the pane is on its way to the wider rest, as the open-then-close case of openCloseAndInterruptedPaneMotionNeverShowsBlankOrReflowsPerFrame already does (300 pt): the test now presents that frame first, and keeps every expectation (384 pt through the retarget and its frames, 184 pt at the landing). * test(layout): a split taking a key hold over lays held content out once, at the split's rest, on its first frame (red) #36677 changed this test to expect a layout at the key spring's rest, which RESIZE KEYS K3 forbids for held content (the key hold keeps the start size; #36478 removed it for terminals only). The test now expects no layout during the key hold, one at the split's rest on the split's first frame, and none at the landing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(layout): a split taking a key hold over lays held content out at its rest as it starts After endKeyResizeHold the held size was still the key hold's start size, and the split's announceRest kept the larger of it and the split's rest (#26118), so a chat that shrinks kept its start width until the landing. The first rest after a takeover now replaces the held size; later retargets keep the larger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Cmd-B, Cmd-D and the two together now move every pane on one timeline: the sidebar, the main pane, the new right pane and the content inside them start in the same frame and land in the same frame.
Leo, 2026-11-05:
Cause (measured)
scripts/measure/split-spring.sh --suite=one-motionon a fleet Mac (macOS 27.0.1, 60 Hz) records every motion beside an agent chat holding 5000 rows. For each pane it notes the first frame and the last frame that moved its frame or its content, along with frame drops and main-thread work, as the motion runs.At base (c9ead45), on Cmd-D (split-right), both pane frames moved from 33 ms to 300 ms. The chat's content stayed at its old size the whole time and changed once, at 300 ms, when the spring landed. The chat waited 267 ms for the new pane. On Cmd-B then Cmd-D, the sidebar ran 50 → 190 ms on its own timer, and the new pane started at 217 ms, chasing the edge while the sidebar slid.
Three things kept the panes apart:
PaneHostView.contentFrame; the provider holds.agentcontent only, cmux-next: prevent agent pane blink during native split motion #26118). On Cmd-D the right pane slid in, and then the chat reflowed. A terminal in the same pane hears its final grid at the spring's start, so the chat was the one that waited.animator(), which AppKit runs on a timer of its own, while the panes step on the window'sMotionClock(display link). Their frames landed in different passes.ScreenContentViewonly moved each spring's target. The panes then chased the moving edge, a spring's lag behind it.Fix: one motion driver
TimedProgress(CmuxNextDesign) is a timed transition stepped on the window'sMotionClock, the clock every pane, divider and tab spring already steps on. The sidebar's width and its clip's reach ride one progress on it, so a slide frame and the springs' frame step together and show in one commit. The tokens (appear and disappear durations) and the ease-out curve (cmux-motion'scubic-bezier) are unchanged, Reduce Motion and the sidebar motion switch still land it in one frame, and a toggle mid-slide turns around from the width on screen.AnimatedFrame.rebase): the pane follows the resize in that frame, and only what the split had left to travel still springs, at the velocity it had. A resize at rest still snaps; a layout change (split, preset, equalize) still springs as before.Can't we just use React?
Not for the panes, but we can use what React-world motion libraries learned, and this PR does.
What is native and what is web in a window today:
SidebarContainerView)CmuxNextLayout:ScreenContentView,PaneHostView) on the window'sMotionClockRightSidebarView)TerminalSurfaceView)webviews/) in WebKit or ChromiumA pane that holds a terminal or a Chromium page can't be a React element: the Metal surface and the page's child window are native views that the layout must place every frame. So React could only lay out the chat, and the problem is how the chat moves relative to the native panes. A React layout library would add a third clock, not remove one.
What we took instead:
frameloop(read, update, render per animation frame), and react-spring runs every spring on onerequestAnimationFrameloop.MotionClockwas already ours for the springs; the sidebar now joins it.AnimatedFrame.rebasedoes that for a viewport resize.Before and after
Start and end of each mover, ms after the key (off-screen pages left out):
On Cmd-D, the chat now lays out once, at its final size, in the spring's first frame. Then it rides the frames, which move together. Before, it waited for the landing.
Per motion, the spread is the largest gap between two movers' first frames (start) and last frames (end); 0 means everything moved in the same frames. Content lag is how long a pane's content kept changing after its frame landed.
Cmd-D beside a long chat drops fewer frames than at base (3 to 1), so the chat's reflow cost doesn't add drops. The before and after runs landed on different Macs of the same pool; each is one run of three passes. Where the "end spread" metric grows on Cmd-D (0 to 300 ms), that is the chat's single layout moving from the landing to the start: the content's only change is now in the first frame.
Tests
Red first: each fix's test is committed before it.
Fleet runs:
scripts/ci/package-test-lane.shsuites on a display Mac (CMUX_TEST_REQUIRE_GUI=1).ViewportResizeMotionTests(aSidebarSlideFrameReachesTheSplittingPanesInTheSameFrame)PaneMotionContentHoldTests(rest size on the first frame, once per spring)SidebarSlideClockTests(the slide steps on the window's motion clock)At the head, every pane-motion and sidebar suite this touches passes:
ViewportResizeMotionTests,PaneMotionContentHoldTests,FrameOwnerS1HoldPathsTests,ChatDockLinkSplitMotionTests,PaneHeldContentLandingTestsandPaneMotionVariantTestspassed at 86e49d2, and run again at 92c7522 (the cmux-next: prevent agent pane blink during native split motion #26118 retarget rule).SidebarToggleSettlementTests,SidebarSlideClockTests,SidebarToggleContentTraceTestsandSidebarSlideHoldsContentTestspassed at 85402ae.Three existing tests encoded the old rule, which held the chat's content until the landing. They now check the new one, a single layout at the spring's start (c214134):
FrameOwnerS1HoldPathsTests,PaneHeldContentLandingTestsandPaneMotionContentHoldTests.SidebarToggleSettlementTestscaught two real regressions of the clock move along the way, both fixed here: a completion one frame early, and a reversal that went one frame past the turn.Observed / Not run
Observed:
Not run:
cmux-ci capture). The bench's own screen recordings are in its artifact.Follow-ups (same principles)
Prior art read (prior-art MUST ACK): #26118, #24153, #24342, #28894, #29894, #30064, #32635, #33607, #33725, #33874, #34170, #34195, #34247, #34380, #35161, #35841, #35940 and the rest of the list; #18840 (open, lucasr1b) is the classic sidebar glide and frozen, a design reference only. Owner: PL-motion-core (Layout motion,
PaneHostView); the sidebar specdesign/sidebar.md(no look change).Changelog
Fixed: Toggling the sidebar (Cmd-B) and splitting (Cmd-D) move every pane together; an agent chat no longer waits for a new pane to slide in before it reflows.
🤖 Generated with Claude Code
Agent session: agent session