Pane motion: no window background inside a pane in any frame of a key resize or a close; containers follow, only the surface or page holds (FRAME OWNER S3)
Summary
FRAME OWNER REFACTOR stage S3, after S1 (#33874, landed). The rule: the shell color never shows inside a pane's rect during a key resize, a held key, a split close or a column close.
Fixture films (ROOT-CAUSE.md P9-P12, shell #FF00FF): a key press opened a shell band of up to 116 px inside the growing pane, held keys up to 762 px for 1.7 s, a pane close a 710 px hole and a column close a 530 px one.
Cause, measured on S1: S1's PaneContentAreaView follows the pane and holds its subviews whole. In a terminal pane that subview is TerminalHostView, so the host (the terminal's ground and padding) kept the start size while the surface already had its final size (announceRest -> restAt -> holdSurface at the press) and the held host clipped it. A springing close hid the closed host in its first frame, so its place showed the shell until the neighbours covered it.
Now containers follow the pane in every frame and only producers hold:
PaneMotionHoldFollowing(CmuxNextDesign, besidePaneMotionHold). WithPaneMotionHold.containersFollow, the content area gives each following subview its own bounds in every frame and hands it the hold once (one shown or added during the hold hears it in its first frame); other subviews stay held as in S1.TerminalHostViewfollows: its frame, ground, padding and scroller follow the pane. OnlyTerminalSurfaceViewholds, at the sizerestAtchose at the press (the final size: one grid per press, S3a), fixed at the pane edge that does not move (PaneMotionHold.producerFrame). No surfacesetFrameSizeper frame: no grid, no reflow per frame; only its origin moves. The terminal measures its rest size from the host size the hold began at.BrowserChromeViewfollows when its Chromium page can hold: the toolbar and page area follow, and the page keeps its size on the held edges throughCEFTabContentView's drag-hold path (heldPageSize, the page backdrop beside it). A WebKit page, docked DevTools, a side panel or a fit keep the whole-chrome hold, as in S1. Agent chats and band mode keep the whole-view hold (owned by S5/S6), as in S1.- One policy, picked in
PaneHostViewper motion kind: key resize (the K3 hold, now producer-only), split/close/zoom springs (live; the provider's rule holds an agent chat), divider drags (TerminalDragTunables, unchanged), window live resize (ResizeCoordinator, unchanged). With containers following,heldPinreads the pane edges that do not move, and the area hears the hold in the key's turn, beforeannounceRest, so the surface takes its final size at the edge that stays. The landing carries the final content (S3a's order is kept). The dead Debug settingmotion.pane.holdContentis deleted: app pane hosts never read it. - S3c, the close hole:
LayoutViewContext.retire(showsGround:)keeps the closing host shown, its content hidden at once (as before), its ring and dim hidden, no hit testing; it paints only the pane's ground, read once at the close.ClosingPaneshas no motion of its own: in each frame the closing pane's rect is a function of its neighbours' presented frames (the one owner of the motion is their springs). Each side sits on the slowest neighbour's facing edge on that side, so nothing between it and any neighbour shows the shell; with neighbours on both sides the leading side keeps the divider gap; a side with no neighbour stays; a side whose neighbour closed too reads that pane's closing rect. Closing hosts stack under every live host of their rank, so a live pane that moves over the place draws above the ground. It is torn down at the landing (releaseRetired). A close taken back shows the same host (host(for:)) and springs on from where it showed. Canvas screens keep the old hide.
Switch: motion.frameOwner.contentPolicy (LayoutTunables, Debug Settings > Springs), default ON, plus LayoutViewContext.contentPolicyOverride for tests (S2's pattern). Off is exactly the old path. The switch is latched when each hold or close begins: a flip applies from the next motion, never inside one. It is a kill switch only: the PR after the first FEAT DOGFOOD BUILD gate passes with it on deletes the switch and the off path.
Area owners told in STATUS 9780 before this PR: PL-motion-core, PL-ghostty, PL-motion-cef.
Testing
Red (the regression test, its own commit and new file before the fix, run on the fleet): PaneGroundCoversPaneTests (f3de79ce6bd).
cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg=suite --arg=Packages/macOS/CmuxNext --arg=PaneGroundCoversPaneTests --ref 22ff6b7c043fefd59ee877c24a0106a47ef57367
- step
d1a161b5a9947b4f7bfbc297(aws-m4pro-7): FAILED as planned, 7 issues, every SETUP check passed. One press H/L: up to 60 pt of the growing pane's content area without ground for 26 frames (K/J 40 pt, 25 frames), the first layer short of itTerminalHostViewevery time; held key (3 presses 4 frames apart): 178 pt, 37 frames; split close and column close: a 389 pt hole where p2 was at frame 0, shrinking over about 20 frames. Drag guard: passed.
Green at earlier heads: step 039772103d3d798da2fdb78d (b46792a85a7) PASSED PaneGroundCoversPaneTests (the reds above, 4 tests, 7 cases); step 154ed71a87f947ca582a3333 (e79b747f538, aws-m4pro-7) passed 32 of 33 suites (the 33rd, PaneMotionWorkTests, runs only with CMUX_PANE_BENCH=1; it runs in the bench step below).
Green at this head df840ecd8ab (feat f2199f27bcd merged in): step 0adbef272942c6dc2b4588da (cmux14) passed 32 of 32 suites. At d67e03c46c8: step f631277ef17d986c0049d9b6 (aws-m4pro-6) passed 32 of 32 suites: PaneGroundCoversPaneTests (the reds), PaneContentPolicyTests (11 tests), the ON/OFF cases of KeyboardResizeContentHoldTests and KeyboardResizeReflowAfterLandingTests, KeyboardResizeOneGridReportTests, the other K3 hold suites, CloseTeardownAfterLandingTests, the S1 suites (PaneContentAreaHoldTests, PaneTabRowHoldLatchTests, TabStripFollowsPaneTickTests), S3a's PaneHeldContentLandingTests, PaneMotionContentHoldTests, the pane, column and row close suites, zoom, burst, terminal final grid, chat dock split, browser pane, parking and hidden-screen suites. PaneMotionWorkTests (gated on CMUX_PANE_BENCH=1) passed in the bench step below.
Review-fix regression tests, red before their fixes: the five tests the review rounds added, copied into one scratch file on e79b747f538 (the head before the review fixes; scratch branch scratch/frame-owner-s3b-review-red 41ac762e9bb, never merged), cmux-ci run ... --arg=S3ReviewFixRedTests --red: step 472b3ffd0c9fa5fb7553a029 (aws-m4pro-8) FAILED as expected on 3 of 5:
twoClosesInARowLeaveNoHole: a hole in the column from frame 5, growing past 209 pt (the first closing pane rode a pane that closed too).aPressAndItsReverseInOneHoldSendEachGridInItsTurn: the reverse press sent no grid, the landing sent one, and the surface resized at the landing.aCloseTakenBackWithoutMotionSnapsToItsPlace: the pane kept its partial closing frame and a spring kept running.- Passed there too, so they are guards, not regression tests for a defect:
aTerminalThatJoinsDuringAHoldFollowsAndRestsAtTheFinalSize(the wrong-grid join I described in 6ea84b8d5f4's message did not reproduce: at e79b747f538 the area already handed the hold before the joining terminal measured its rest size) andaClosingPaneLeavesNoGroundForTheLanding(in that layout the neighbour covers the closing pane's place by the landing). So the one-sided collapse in d67e03c46c8 has no red test: it makes a closing pane with a neighbour on one side only rest at zero size; no unit case shows ground left at the landing without it.
New and changed tests:
PaneContentPolicyTests(new): the latch (the switch flipped off at frame 4 of a spring: that spring stays on its path, the next press takes the off path); the anchor, one case per side (H on p2: the surface rides the trailing edge; L on p1: the leading edge; K on p2: the bottom; J on p1: the top; the shrinking pane on its own still edge), with the host covering its pane in every frame; one grid per press (each terminal asks its PTY for its final grid once, its surface takes at most one new size per press, grid reports <= terminals); a held key (three presses 4 frames apart: per terminal at most one grid per press, the last the final; the surface resizes only in a press's turn); a terminal that joins during a hold (a tab switch at frame 6) follows from its first frame and rests at the final size; S3c: the closing pane shows the ground read at the close (a repaint during the close does not reach it), shrinks monotonically, and after the landing its host, ring, content and layers are out of the tree; two closes in a row (the second before the first landed) leave no hole in the column in any frame and keep closing hosts under live ones; a close taken back shows the same host and terminal, from where it showed.- ON cases (the OFF cases kept):
KeyboardResizeContentHoldTests.theProducerResizesOnceAndTheContainersFollowEveryFrame(on: each terminal host has its pane area's size in every frame, the surface one size; off: the host resizes at most once) andKeyboardResizeReflowAfterLandingTests.theProducerLaysOutOnceAndTheContainersEveryFrame(the surface lays out once, in the key's turn, never in a spring frame or after the landing; the hosts every frame; one grid per terminal). CloseTeardownAfterLandingTests: the springing-close and take-back cases run with the switch off (hidden host, as before) and on (shown host with only its ground, no content, no ring, no hit).PaneContentPolicyBench(new,CMUX_PANE_BENCH=1, release, never in CI): below.
The refactor owner's 8 notes
- Default ON; latched per hold (a key hold latches it in
PaneHostViewwhen the hold begins, a close inScreenContentView.retireClosingwhen it begins). Test:PaneContentPolicyTests.aSwitchFlipMidSpringAppliesFromTheNextMotionflips the switch off at frame 4 of a spring: that spring finishes on the path it started on (every frame still covered), the next press takes the off path. - Anchor: the surface stays at the pane edge that does not move. One case per side in
PaneContentPolicyTests.theSurfaceStaysAtThePaneEdgeThatDoesNotMove(H on p2: trailing; L on p1: leading; K on p2: bottom; J on p1: top; the shrinking pane on its own still edge). One deliberate difference, flagged: a pane that grows down with its prompt in the lower half (the terminal's ownmotionAnchoranswers.bottom) keeps the surface on its moving bottom edge, so the prompt on the final grid's last row stays in view (S1 and K3 did the same); the surface is taller than the pane then, so it still covers it and no shell shows. With the still edge the prompt would vanish for the whole spring. Every other case keeps the still edge. - One grid per press with the switch ON:
PaneContentPolicyTests.aKeyPressSizesEachSurfaceOnceAndSendsOneGrid(H/L/K/J: each terminal's PTY gets[final], its surface takes at most one size, grid reports <= terminals),aHeldKeySizesEachSurfaceOnlyInAPressTurn,aPressAndItsReverseInOneHoldSendEachGridInItsTurn, and the ON case ofKeyboardResizeReflowAfterLandingTests(one grid per terminal; the surface lays out once, in the key's turn). // perf-hot: 4ms motion-frameonPaneContentAreaView.showHeld/layout/setFrameSize,PaneHostView.layoutClip,TerminalHostView.layoutandfollowedSurfaceFrame,ClosingPanes.rects,presentClosingPanes. Bench below.- S3c: the ground is read once at the close (
aClosingPaneShowsItsGroundReadAtTheCloseAndLeavesNothingBehind: a repaint of the terminal during the close does not reach the closing ground); the same host when a close is taken back (aCloseTakenBackShowsTheSameHost,CloseTeardownAfterLandingTestson and off,aCloseTakenBackWithoutMotionSnapsToItsPlace); afterreleaseRetiredthe closed pane's host, ring and content are out of the tree and the window lost its layers (a view-tree layer count). - Films and typing-load-l6, the refactor owner's (px-keys-single-3k-s1r, px-keys-held-2-s1r, px-close-pane-3-s1r, px-close-column-c3-s1r, px-col-drag-2-s1r). Films at a6d403724d5; d67e03c46c8 changes only the one-sided close (
ClosingPanes.collapse); the filmed closes have neighbours on both sides.
Film verdict (refactor owner pixel-perfect): build a6d403724d5 (job 08f42537), switch round ab-s3-sw-22 on cmux22, 120 Hz, the switch OFF then ON in the same app, x1 and Reduce Motion, judge_pixel v7 (c22403233dd7). Shell color inside a TERMINAL pane, frames (max device px), OFF -> ON at x1: key press 83 (115) -> 3 (2); held keys 323 (637) -> 10 (56); close pane 18 (67) -> 18 (56; 2 frames over 2 px); close column 28 (1714) -> 2 (56); column drag 0 -> 0. Every ON frame over 2 px is the tab strip's trailing notch (the shell between the strip's end and the pane edge, the same at rest) that moves with its pane across a scan column: a judge count, not a content gap (sent to R-HARNESS). Close hole (S3c): the agent pane's hole at a column close is gone (OFF 10 frames, 61 px; ON 0). The browser toolbar follows the pane (OFF: it is cut during a held key). Chromium pages: the same in both arms; this is S5 scope (the fixture turns the page backdrop off, and the page ground is the shell color in this fixture). Reduce Motion: the same counts in both arms. No other layer is worse with ON by more than 3 frames (tab strip edge off ON/OFF: 3/38, 4/179, 19/19, 3/32, 154/151). Dropped refreshes in the motion at x1, ON/OFF: 1/0, 6/1, 5/4, 1/2, 1/0 (held keys ON: 2 app drops, 4 compositor drops); single films, and the bench shows no main-thread cost. Not filmed: the one-sided close change after a6d403724d5, and a last-column close with a camera move (follow-up S3d).
The films are from a6d403724d5; the later commits change only the one-sided close (ClosingPanes.collapse) and merge feat f2199f27bcd, which removes S3's reader of the deleted S1 switch (S1 ON was the default, so the behavior is the same). Not filmed: the one-sided close change and the merge.
Typing under load (refactor owner pixel-perfect): typing-load-l6, P0 runner flags (+15 GB swap, 120 Hz), PR build a6d403724d5 (job 08f42537), the switch set after the relaunch in the same app; key-to-commit ms p50/p95/p99. Switch OFF, 3 runs (cmux18 x2, cmux22): 3.28/9.31/14.54 mean (p99 14.22-15.06). Switch ON, 4 runs (cmux18 x3, cmux22): 3.29/9.39/15.41 mean (p99 14.18-16.87). The two interleaved pairs: cmux18 OFF 15.06 / ON 14.18; cmux22 OFF 14.22 / ON 14.41. No stage of the key path moves with the switch (dispatch wait, dispatch run, PTY round trip, parse to commit). The two slowest ON runs were the first two of the day on this build; the interleaved pairs show no gap. Every key over 40 ms in these runs is the same typist key in every build today (see below), OFF and ON alike. Verdict: GO.
Flag (the refactor owner's judgment call): the ON mean p99 is 0.87 ms over OFF across all runs, inside the run-to-run band; the decision is made on the interleaved pairs. - PL-motion-core, PL-ghostty and PL-motion-cef told in STATUS 9780 before this PR.
- ON cases added with their OFF cases kept:
KeyboardResizeContentHoldTests.theProducerResizesOnceAndTheContainersFollowEveryFrame(policy:)(on: each terminal host has its pane area's size in every frame, the surface one size; off: the host resizes at most once) andKeyboardResizeReflowAfterLandingTests.theProducerLaysOutOnceAndTheContainersEveryFrame(the surface lays out once, in the key's turn; the hosts every frame).
Performance
PaneContentPolicyBench (CMUX_PANE_BENCH=1, CMUX_SWIFT_SUITE_CONFIGURATION=release, never in CI): main-thread time of each display frame (advanceFrame, the layout pass, the views' and layers' display, the landing's after-commit release) of 12 key presses (Ctrl-Shift-L/H alternating on the middle pane) and one close, 3 panes with real terminals (libghostty surfaces) in a 1600 x 1000 window, 5 rounds, switch off and on interleaved in one process. Step 794c2a6d54ff80ae4803188d (aws-m4pro-4, at ff2295a4615; the key and close paths there are the ones at this head except for later review fixes in the take-back, follower and page-hold code):
| frames | switch | p50 | p95 | p99 | max |
|---|---|---|---|---|---|
| key presses (1620 frames) | off | 0.123 ms | 0.131 ms | 0.135 ms | 0.166 ms |
| key presses (1620 frames) | on | 0.131 ms | 0.140 ms | 0.144 ms | 0.345 ms |
| close (190 frames) | off | 0.136 ms | 0.144 ms | 0.214 ms | 0.225 ms |
| close (190 frames) | on | 0.141 ms | 0.149 ms | 0.219 ms | 0.238 ms |
On costs about 0.01 ms per frame (p99 0.144 ms vs a 4 ms budget per 120 Hz frame). The same step ran PaneMotionWorkTests (the motion-frame work counts: a split frame touches only the panes it moves, panes that only move lay out nothing, a divider drag step touches only the panes beside it, a strip pan computes no clip): passed. Not measured: a browser pane's chrome following per frame (no Chromium runtime in unit tests).
Review
- Codex (gpt-6.1-sol,
codex exec --sandbox read-only, no tools on this VM: the diff, the changed files in full and the callees were in the prompt, in three parts: core and terminal host, close host, browser chrome). Round 1 at 6ea84b8d5f4: FIX (a compile error, a hole when two panes close in a row, follower and page-hold lifecycle gaps). Round 2 at ff2295a4615: FIX (a press and its reverse in one hold; a take-back without motion). Round 3 at 75dbed3d8e6: FIX (a page hidden and shown again during a hold). Round 4 at f4d815e14d9: FIX (a broken doc comment; a take-back's order in reconcile). Every finding was checked and fixed with a test where one fits. Round 5 at 7827a1fc5cb and round 6 at 1aa74831efe (feat merged in): SHIP on all three parts; round 7 at a6d403724d5: SHIP (comment 6613347146); round 8 at d67e03c46c8 (the one-sided close collapse and two close tests): SHIP (comment 6613789044); round 9 at this head df840ecd8ab (feat f2199f27bcd merged in, the S1 and S2 switches deleted there): SHIP on all three parts, receipt comment 6615023863. - Claude review subagent at 6ea84b8d5f4: FIX (9 findings: a compile error; a pane nothing closes over popped at the landing; row-offset carry; the prompt edge; a follower shown mid-hold; a weak join-test assert; a shared switch in the test rig; perf evidence; square closing corners), all handled in the commits after it. Its re-review of the repair (at 7827a1fc5cb, the same S3 diff) runs now.
Changelog
Fixed: During a keyboard pane resize, a held resize key, a pane close or a column close, no window background shows inside a pane any more: each pane's own background follows it in every frame, and a closing pane shrinks with its own background as its neighbours move in.
Proof
No capture: a motion change; the refactor owner's 120 Hz fixture films (judge_pixel) are the proof, named above. GUI proof on a display worker (--display --label gui-session-v1) at this head df840ecd8ab: step 75ca2c19d2081d32cc91d723 (cmux10s-Mac-mini) passed 8 of 8 suites (PaneGroundCoversPaneTests, PaneContentPolicyTests, KeyboardResizeContentHoldTests, KeyboardResizeReflowAfterLandingTests, KeyboardResizeOneGridReportTests, CloseTeardownAfterLandingTests, PaneContentAreaHoldTests, TabStripFollowsPaneTickTests); at d67e03c46c8, step 93dc698602a80fe9620418eb passed 8 of 8 too. App compile: cmux-ci compile-check --app --release at d67e03c46c8, job d93aa6c98554366ecef14c9a: passed. At this head a Release app compile fails on feat's base red (Cloud Tunnel defaultPath, fix #34198; PR comment 6615032164), not on this PR.
Known gap, follow-up S3d (not a regression)
A close of the LAST column of a strip whose camera moves back (a full-width column and a 2/3 one, Cmd-W on the right one; film P12's close of a new column) still shows the shell where the column was, as on the base: no pane frame moves in reconcile, only the camera, which syncScroll sets after it, so the closing pane is hidden as one that nothing closes over. Found by the Claude re-review of the repair at 7827a1fc5cb (P1). Follow-up S3d (refactor owner's decision): the stillness decision after the camera is set, the closing pane collapsing on the camera's share; red test first (PaneCloseCameraGroundTests), opened after this PR merges. Also not covered here (rare, P2 of that review): a docked column's close (its pane has no neighbour in its own space, so it hides at once as before), a closing pane in a horizontal scroll area is not clipped to the area window, and a scroll area that dissolves during a close.
Observed / Not run
Observed (fleet, read from the step logs): the red step failing as planned at 22ff6b7c043; PaneGroundCoversPaneTests passing from b46792a85a7 on; 32 of 32 suites passing at this head (0adbef272942c6dc2b4588da) and at d67e03c46c8 (f631277ef17d986c0049d9b6); the GUI proof 8 of 8 at both; the bench and PaneMotionWorkTests (794c2a6d54ff80ae4803188d); Codex SHIP on all three parts at this head.
Base finding, not S3 (the refactor owner's, for the typing owner): in every typing run today (19 runs, 10 builds, base and PR alike), the typist's first Ctrl-C of the second half (a real HID Ctrl-C) runs 75-106 ms on the main thread in its own dispatch (dispatch run 76-106 ms; the PTY round trip after it is 1-4 ms).
Not run or not observed by me: the 120 Hz films and typing-load-l6 (the refactor owner runs them, one mini); a browser pane in the cost bench (unit tests have no Chromium runtime, so the browser chrome's per-frame Auto Layout cost is not measured); a scroll area that dissolves during a close (closing panes there keep their last x; not handled, rare); a vertical held key and a vertical reverse press (the held-key and reverse cases are horizontal; single presses cover all four directions); the lower-half prompt exception has no test with output that moves the cursor down.