Skip to content

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) - #34170

Merged
lawrencecchen merged 34 commits into
feat-cmux-nextfrom
fix/frame-owner-s3-content-policy
Nov 3, 2026
Merged

lawrencecchen merged 34 commits into
feat-cmux-nextfrom
fix/frame-owner-s3-content-policy

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

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, beside PaneMotionHold). With PaneMotionHold.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.
  • TerminalHostView follows: its frame, ground, padding and scroller follow the pane. Only TerminalSurfaceView holds, at the size restAt chose at the press (the final size: one grid per press, S3a), fixed at the pane edge that does not move (PaneMotionHold.producerFrame). No surface setFrameSize per 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.
  • BrowserChromeView follows when its Chromium page can hold: the toolbar and page area follow, and the page keeps its size on the held edges through CEFTabContentView'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 PaneHostView per 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, heldPin reads the pane edges that do not move, and the area hears the hold in the key's turn, before announceRest, 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 setting motion.pane.holdContent is 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. ClosingPanes has 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 (f3de79c).
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 it TerminalHostView every 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 (b46792a) PASSED PaneGroundCoversPaneTests (the reds above, 4 tests, 7 cases); step 154ed71a87f947ca582a3333 (e79b747, 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 df840ec (feat f2199f2 merged in): step 0adbef272942c6dc2b4588da (cmux14) passed 32 of 32 suites. At d67e03c: 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 e79b747 (the head before the review fixes; scratch branch scratch/frame-owner-s3b-review-red 41ac762, 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 6ea84b8's message did not reproduce: at e79b747 the area already handed the hold before the joining terminal measured its rest size) and aClosingPaneLeavesNoGroundForTheLanding (in that layout the neighbour covers the closing pane's place by the landing). So the one-sided collapse in d67e03c 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) and KeyboardResizeReflowAfterLandingTests.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

  1. Default ON; latched per hold (a key hold latches it in PaneHostView when the hold begins, a close in ScreenContentView.retireClosing when it begins). Test: PaneContentPolicyTests.aSwitchFlipMidSpringAppliesFromTheNextMotion flips 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.
  2. 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 own motionAnchor answers .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.
  3. 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 of KeyboardResizeReflowAfterLandingTests (one grid per terminal; the surface lays out once, in the key's turn).
  4. // perf-hot: 4ms motion-frame on PaneContentAreaView.showHeld/layout/setFrameSize, PaneHostView.layoutClip, TerminalHostView.layout and followedSurfaceFrame, ClosingPanes.rects, presentClosingPanes. Bench below.
  5. 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, CloseTeardownAfterLandingTests on and off, aCloseTakenBackWithoutMotionSnapsToItsPlace); after releaseRetired the closed pane's host, ring and content are out of the tree and the window lost its layers (a view-tree layer count).
  6. 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 a6d4037; d67e03c changes only the one-sided close (ClosingPanes.collapse); the filmed closes have neighbours on both sides.
    Film verdict (refactor owner pixel-perfect): build a6d4037 (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 a6d4037, and a last-column close with a camera move (follow-up S3d).
    The films are from a6d4037; the later commits change only the one-sided close (ClosingPanes.collapse) and merge feat f2199f2, 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 a6d4037 (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.
  7. PL-motion-core, PL-ghostty and PL-motion-cef told in STATUS 9780 before this PR.
  8. 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) and KeyboardResizeReflowAfterLandingTests.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 ff2295a; 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 6ea84b8: FIX (a compile error, a hole when two panes close in a row, follower and page-hold lifecycle gaps). Round 2 at ff2295a: FIX (a press and its reverse in one hold; a take-back without motion). Round 3 at 75dbed3: FIX (a page hidden and shown again during a hold). Round 4 at f4d815e: 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 7827a1f and round 6 at 1aa7483 (feat merged in): SHIP on all three parts; round 7 at a6d4037: SHIP (comment 6613347146); round 8 at d67e03c (the one-sided close collapse and two close tests): SHIP (comment 6613789044); round 9 at this head df840ec (feat f2199f2 merged in, the S1 and S2 switches deleted there): SHIP on all three parts, receipt comment 6615023863.
  • Claude review subagent at 6ea84b8: 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 7827a1f, 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 df840ec: step 75ca2c19d2081d32cc91d723 (cmux10s-Mac-mini) passed 8 of 8 suites (PaneGroundCoversPaneTests, PaneContentPolicyTests, KeyboardResizeContentHoldTests, KeyboardResizeReflowAfterLandingTests, KeyboardResizeOneGridReportTests, CloseTeardownAfterLandingTests, PaneContentAreaHoldTests, TabStripFollowsPaneTickTests); at d67e03c, step 93dc698602a80fe9620418eb passed 8 of 8 too. App compile: cmux-ci compile-check --app --release at d67e03c, 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 7827a1f (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 22ff6b7; PaneGroundCoversPaneTests passing from b46792a on; 32 of 32 suites passing at this head (0adbef272942c6dc2b4588da) and at d67e03c (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.

lawrencecchen and others added 30 commits November 3, 2026 08:28
…esize; a pane width change starts no strip move (FRAME OWNER S1, red)

FRAME OWNER REFACTOR S1. Lawrence (P0): "the tabbar is not perfectly in sync
with the animation". Film resize-keyboard (FEAT DOGFOOD BUILD 1d8e759e, feat
5fe33a3, frames 244-270, still E1): in a key resize the shrinking pane's
tab strip stays where it was for the whole spring, the moving pane edge cuts it
("python3" -> "on3"), and the whole tab jumps 115 px 3-4 frames after the edge
rests. Trace of the same round, samples 285/286: at the landing's release the
strip has its new x but its old width; the width follows one tick later.

Cause (code): the key-resize hold (RESIZE KEYS K3, PaneHostView.contentFrame)
holds the pane's whole content view (PaneContentView), and the strip is part of
it. Separately, TabStripView.layout starts a TabStripMove (a 200 ms
render-server tween on its own clock) for a width change while a tab close is in
flight (ui.animations.newTab on).

New file TabStripFollowsPaneTickTests (the app's own PaneContentView in each
pane, frames at a fixed 120 Hz dt, the landing's after-commit release run by the
test; each frame checked after its tick and after the window's layout pass):
- (b) aKeyResizeKeepsEachStripOnItsPaneInEveryFrame (Ctrl-Shift-H/L on p1 | p2,
  Ctrl-Shift-K/J on p1 over p2): each strip's leading, trailing and top edges
  equal its pane's content rect in device pixels, its tabs are laid out at that
  width, the content never shows under the strip, and the content below the
  strip still resizes at most once per press. Expected here: red (the strip
  stays at its start rect; at the release it has the new x and the old width).
- (a) aPaneSpringKeepsEachStripOnItsPaneInEveryFrame (split, close, ratio
  spring): the same edges in every frame. Expected here: green (the refactor
  owner's trace fact 2); it guards the stage.
- (c) aPaneWidthChangeStartsNoStripMoveWhileATabCloses: a tab closes on its
  tween and the pane narrows by 40 pt: no new TabStripMove generation on the
  cells' layers, the close keeps its own move and its closing tab (cx-y2uf5).
  Expected here: red (a new tab.move generation per width change).

The tests turn the stage's Debug switch motion.frameOwner.tabStrip on by its key
(no S1 type is needed to compile here).

Command: cmux-ci run --class isolated --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=TabStripFollowsPaneTickTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…strip whose tab widths follow its width; the guard covers divider release and column springs (FRAME OWNER S1, red)

Red run of the previous commit (step 2e308dd287a4fdff7e4b592d, aws-m4pro-7):
the horizontal key presses failed as expected (in every frame the strips stay
at their start edges, p1 trailing 600 vs pane 597...540, p2 leading 601 vs
598...541; at the landing's release p2's strip has the new x and the old width,
trailing 1140 vs pane 1200: trace samples 285/286). Two tests passed where they
had to fail, both because of the fixture, not the code:
- vertical presses: the K3 hold pins held content at the edge the content asks
  for (PaneMotionTargeting); the plain test view asked for none (top), so the
  held pane content rode the moving top edge with its strip. A terminal whose
  prompt is on its last row asks for the bottom (the common shell case): then
  the strip is held away from the top edge. The test content now answers an
  anchor: bottom for the horizontal and vertical red cases, top for two
  vertical guard cases (a page).
- the width-change test: 8 tabs overflow at their minimum width (74 pt), so
  their widths do not follow the strip and no move can start. It now uses 4
  tabs (3 after the close, between the minimum and maximum widths), requires
  that their widths change, and narrows the pane by 60 pt.

The refactor owner asked S1 to cover closes, drag release springs and column
springs too. The guard aPaneSpringKeepsEachStripOnItsPaneInEveryFrame gets
dividerRelease (the layout a divider drag's release makes, seeded with the
pointer's velocity: ScreenContentView.releasedDivider, SplitMotion.seed) and
columnWidth (two columns; the first one's width springs from 0.5 to 0.35 of the
window, the second one moves). The ratio case now goes through
LayoutModel.setSplitRatio, as the divider and the keyboard do. The guard was
green on the base for split, close and ratio.

Expected on this commit (no fix): red for the four bottom-anchored key presses
and the width-change test; green for the two top-anchored vertical presses and
for the guard.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=TabStripFollowsPaneTickTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --key KEY --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…piles under default main-actor isolation) (FRAME OWNER S1, red)

The red run of 43910d3 (step 0a74af4c9df406cd8458d52f, aws-m4pro-9) did
not compile: KeyPress is nonisolated (a test argument), and its description
compared PaneMotionAnchor values with ==, whose Equatable conformance is
main-actor isolated in CmuxNextDesign (IsolatedConformances). A switch over
the two cases needs no conformance. No test changes otherwise.

Expected on this commit (no fix): red for the four bottom-anchored key presses
and the width-change test; green for the two top-anchored vertical presses and
for the guard.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=TabStripFollowsPaneTickTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --key KEY --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arget (FRAME OWNER S1, red)

The refactor owner's film facts (px-5fe33a3, ROOT-CAUSE.md P9): a held key
keeps the first press's K3 hold through every retarget, so the shell color
shows between the held content and the moving pane edge for up to 762 px and
1.7 s. New case aHeldKeyKeepsEachStripOnItsPaneThroughEveryRetarget: three
presses 4 frames apart (a key repeat at about 30 Hz) retarget one spring
(Ctrl-Shift-H/L on p1 | p2, Ctrl-Shift-K/J on p1 over p2, content that rides
the bottom edge). In every frame of the chain and at its landing's release,
each strip is on its pane; the content below each strip lays out at most once
for the chain.

Expected on this commit (no fix): red for the four cases (the strip is held
with the content), like the single-press cases.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=TabStripFollowsPaneTickTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --key KEY --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, Debug toggle motion.frameOwner.tabOutline (FRAME OWNER S1)

The FRAME OWNER REFACTOR films judge each tab's edges against its pane's edge
in every frame of a 120 Hz capture (fixture kit cp/pixel-perfect-kit). The
strip's own edges need no code (appearance.surfaces.tabBar.color #00FF00,
opacity 1, paints the strip's frame); the tabs' edges need this hook.

TabFrameFixture (CmuxNextTabs): with the Debug Settings toggle
motion.frameOwner.tabOutline (DesignTunables, section Springs, default off;
DEBUG and CMUX_BENCH_HOOKS builds only, absent from shipped builds) every tab
cell's root layer gets a 1 device-pixel #FFFF00 border. A layer border draws
inside the layer's bounds: no frame, layout or hit test changes, and it is set
with actions off (no animation). The strip outlines each new cell and follows
the toggle while it is in a window (an ObservationStream, cancelled when it
leaves). In a bench build the tunable store is off until stage S0 lands
(debug.tunables activate); the kit calls activate first.

Refactor owner's answer (S1 review, point 3): "in S1, as its own small commit".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry frame; only the content area below it holds (FRAME OWNER S1)

FRAME OWNER REFACTOR stage S1, behind the Debug Settings switch
motion.frameOwner.tabStrip (DesignTunables.frameOwnerTabStrip, section Springs,
default ON per the refactor owner's review; off films the old path in the same
build; the switch goes once S1's films pass).

Lawrence (P0): "the tabbar is not perfectly in sync with the animation".
Film resize-keyboard (FEAT DOGFOOD BUILD 1d8e759e, feat 5fe33a3, frames
244-270, still E1): the shrinking pane's tab strip stayed where it was for the
whole spring, the moving pane edge cut it, and the tab jumped 115 px 3-4 frames
after the edge rested. Film px-5fe33a3 (ROOT-CAUSE.md P9): a held key keeps the
first press's hold through every retarget (762 px for 1.7 s). Trace samples
285/286: at the release the strip had its new x and its old width.

Cause: the key-resize hold (RESIZE KEYS K3, PaneHostView.contentFrame) held the
pane's whole content view, strip included, and released it after the landing
commit with one frame set (the strip laid out a pass later).

The fix, a general mechanism (S3 extends it to the terminal host and the
browser chrome):
- A hold is a value (PaneMotionHold: the edges the held content rides, in
  CmuxNextDesign) handed down the pane's view tree. In-process containers follow
  the pane's rect in every frame; only what draws on another thread or in
  another process keeps its start size.
- PaneHostView (K3 hold only): with the switch on (latched when the hold
  starts) and content that adopts PaneMotionHolding, the content view takes the
  clip's bounds in every frame, so PaneContentView.layout lays its strip,
  toolbar and vertical list out at the pane's rect in the frame tick
  (applyPresentation -> host.layoutSubtreeIfNeeded), in that frame's main-thread
  turn and CA transaction. The host hands the content area the edges K3 already
  picks (heldPin, same rule) and lets it go in the release, before
  notifyPaneMotionSettled (S3a's release order is kept). restSizeChange keeps
  measuring from the held size. Split, close, zoom and motion.pane.holdContent
  springs are unchanged.
- PaneContentAreaView (CmuxNextDesign, the MainPaneCardView.ContentHold
  pattern) is PaneContentView.contentHost: rounded, it clips; while held its
  frame follows the pane and its subviews keep their frames, shown on the held
  edges through its bounds origin (one bounds write per frame, no subview frame
  or layout change: terminals, pages and chats show what they showed before).
  At the release each subview takes the area's size once. A view that joins
  during the hold (a tab switch) gets the held rect (contentBounds).
- PaneContentView: while its area holds it does no content placement, no forced
  content layout, no launch-image clear, no parked-page layout; onResize once,
  at the release. Kept whole-view hold (documented exception, owned by S5/S6):
  in a browser's band, and when a page draws its tools in the tab row (a chat:
  page pixels).
- TabStripView.layout: with the switch on, a viewport change lays the tabs out
  at once and starts no TabStripMove; a tab closing on its own move keeps it
  and its cell (cx-y2uf5, TabCloseWidthChangeTests).
- A strip move that starts inside a frame tick begins at that frame's link time
  (MotionClock.frameTimestamp, the time its springs step from), not at
  CACurrentMediaTime(): one time base for a tab move that overlaps a pane motion.

Red (TabStripFollowsPaneTickTests, the commits before this one, on the fleet):
step 45906c61ceaeacad9ce4248d (aws-m4pro-6, c884b2a) FAILED as expected,
9 issues: one key press H/L/K/J 104-110 strip frames off their pane; a held key
140-146; a pane width change during a tab close started a new strip move.
New guard: PaneContentAreaHoldTests (switch off: the whole view holds as before;
on: the held content keeps its frame every frame and hears the landing at its
final frame; a page sharing the tab row holds whole). Cost bench:
TabStripFollowsPaneTickBench (CMUX_KEY_RESIZE_STRIP_BENCH=1, off vs on).
Green: on this commit, named in the PR body.

Area owners told in STATUS: PL-instant (cmux-1c, tab strip), PL-motion-core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tripFollowsPaneTickTests.Content is final) (FRAME OWNER S1)

The green step 3dd3dd9b51464213610c1075 at 813ef9a did not compile:
PaneContentAreaHoldTests.Settling inherited the final
TabStripFollowsPaneTickTests.Content. It is its own NSView now: it rides the
bottom edge (PaneMotionTargeting) and records its frame when it hears the
landing (PaneMotionSettling). No source change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ick of a key press, held keys or a close (FRAME OWNER S3, red)

FRAME OWNER REFACTOR S3 (stacked on S1's fix/frame-owner-s1-strip-tick). Fixture
films (ROOT-CAUSE.md P9-P12, shell #FF00FF): a key press opens 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.

New file PaneGroundCoversPaneTests: the app's PaneContentView with a real
TerminalSession in each pane, a manual 120 Hz clock. At every tick:
(a) one Ctrl-Shift-H/L/K/J press and (b) three presses 4 frames apart (one
spring): each pane's terminal ground (TerminalHostView) covers its content area
(PaneHostView.roundedRect); a miss names the first layer from the host down
that stops short of the area. (c) a close of the middle of three split panes
and (d) of the middle of three columns: the closed pane's place is covered by
pane hosts beyond the gap it rests at. (e) a divider drag: the same ground rule
(a guard: green today; a page's drag gap is S5's).

Expected on this commit: (a), (b) fail (PaneContentView held at its start size
by K3 in the growing pane), (c), (d) fail (the closed pane's host is hidden at
the close's first frame), (e) passes. Every test checks its rest state first
(SETUP lines), so a setup fault never reads as a red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… draws (layer display: re-truncated tab titles) (FRAME OWNER S1)

TabStripFollowsPaneTickBench (CMUX_KEY_RESIZE_STRIP_BENCH=1, never in CI)
timed advanceFrame, the layout pass and the views' display, but not the
layers' display that the frame's Core Animation commit runs on the main
thread: a tab title re-truncated at a new width redraws its text there. Each
frame now also displays every layer that needs it.

First numbers, without the layers' display (step 796c551916519c57f11c79bb,
aws-m4pro-7, release, d1d4dd4; 3 panes x 6 tabs, 12 presses x 5 rounds,
1620 frames per mode): switch off p50 0.051 / p95 0.061 / p99 0.263 ms;
on p50 0.181 / p95 0.267 / p99 0.274 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wner-s1-strip-tick

Conflicts, both kept:
- PaneContentView.layout: feat's offerTabRow(..., toolbar:) and the tab-row
  hole paint (#33407), with S1's onResize at the hold's end.
- MotionClock: S2 (#33725) steps the springs by FrameTick.motionElapsed; S1
  keeps that step and sets frameTimestamp from the new
  FrameTick.motionTimestamp (next to motionElapsed): the frame's show time
  (targetTimestamp) with motion.frameOwner.timing on, else the link time, so
  a strip move that starts inside a frame begins on the time base its springs
  step on. With the S2 switch off (its default) it is the link time, as
  before this merge.
…ens imports no QuartzCore) (FRAME OWNER S1)

The merged head 8579f4c did not compile (green step
d1dbcc7edf42348e85ccdead, GUI step b4309e95f6f8a5b671411e75):
"cannot find type 'CFTimeInterval' in scope" in FrameTick+Motion.swift, whose
module imports only CmuxNextWakeups. CFTimeInterval is a typealias of Double;
the property is a Double now, the same value (seconds on the CACurrentMediaTime
base). No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wner-s1-strip-tick

Conflict in PaneHostView.endKeyResizeHold with S3a (#33607, D1): feat's form
is kept (one-line guard, layOutLetGoContent); S1 keeps its one change there,
heldContentSize = heldContentSize ?? content.frame.size: with only the content
area held (motion.frameOwner.tabStrip) the content view is already at the
pane's size, so the size a structural takeover keeps holding is the held one.
S3a's release order composes with S1: layOutLetGoContent runs layoutClip,
which lets the held area go (syncHeldArea), then lays the subtree out, then
the content hears the landing.
…wner-s3-content-policy

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift
…s for the landing (FRAME OWNER S1, red)

Review of #33874 at 9830311 (both reviews, the chat-in-tab-row gap). With
motion.frameOwner.tabStrip on, a key resize holds only a pane's content area
and the pane content view lays out at the pane's rect in every frame, so a
page that draws its tools in the tab row (PaneTabRowSharing, a chat) is asked
on every frame whether its tools fit at the pane's live width. The page itself
is held until stage S5: when the fit flips during the spring, the content area
grows over the strip row and the held page is offered the row, though it
cannot show its tools there yet.

The refactor owner's decision: the fit decision is latched with the hold. The
decision the hold started with stays until the landing; at the landing the row
and the content area change in the same layout pass.

New file PaneTabRowHoldLatchTests: p1 | p2, a chat page in p1 whose tools fit
its tab row only once p1 is 30 pt wider than at rest; Ctrl-Shift-L on p1 grows
it about 60 pt. No frame of the spring may offer the page the row or move the
content area over it; after the landing's release both happen.
Expected on this commit: red (from the frame where the pane passes the width,
the page is offered the row and the content area covers it).

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=PaneTabRowHoldLatchTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --key KEY --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gan with until the landing (FRAME OWNER S1)

Review of #33874 at 9830311 (the chat-in-tab-row gap; the refactor owner's
decision: latch the fit with the hold). PaneContentView.layout decided on every
layout whether a page's tools fit the tab row (PaneTabRowSharing) at the
pane's width. With S1 the pane content view follows the pane in every frame
of a key resize while its area holds the page, so the decision could flip
mid-spring: the area grew over the strip row and the held page was offered
the row it cannot show yet (S5 owns pages).

The decision at rest (offered or not, and the tools' width) is kept in
`tabRow`; while the content area holds, layout reads it instead of asking the
page. The hold's release lets the area go and then lays the pane out
(layOutLetGoContent), so at the landing the row and the content area change
in the same pass. paneMotionHoldArea reads the same kept decision (a page that
shares the row at the hold's start holds whole, as before). App lines +1.

Red: PaneTabRowHoldLatchTests at the commit before this one (fleet step named
in the PR). Green: on this commit, named in the PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s with motion.frameOwner.tabStrip on and off (FRAME OWNER S1)

Both reviews of #33874 at 9830311 (BLOCK and FIX): the green step
3678b805ff15d52b72a9c576 failed
KeyboardResizeOneGridReportTests.aCloseDuringASecondPressSendsEachTerminalOnlyItsFinalGrid
at its precondition (line 226): it read "p1 is at rest with its content held"
as `host.content.frame.size != host.contentRect.size`, the whole-view hold.
With S1's switch on (the default) the pane content view follows the pane and
only its content area holds, so the precondition was false and the test
stopped before its real checks; nothing at the head covered S3a's takeover
with S1's area hold.

The test now runs with the switch on and off. On: the content view is at its
rest size and its area holds. Off: the whole content view holds and the area
does not. The checks after the precondition are unchanged (one grid for the
taken-over terminal, its final one; no start size again; the surface rests at
its host size), and the last check now also requires the area to have let go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e tick's time (FRAME OWNER S1)

Both reviews of #33874 at 9830311. S1 started a strip move that is made
inside a frame tick at the tick's time (MotionClock.frameTimestamp; after the
S2 merge FrameTick.motionTimestamp, the frame's show time with S2's switch on).
Two defects followed:
- with S2's switch on the begin time is in the future, and additive moves set no
  backward fill, so a frame drawn before it shows the end value, then jumps back;
- with the link time in the past, noteClosing's goesAt (CACurrentMediaTime() +
  duration) is later than the move's end, so moveEnded can leave a closing tab.
The alignment covered only moves made inside a tick (drag autoscroll; none in
the films), not the overlap the design named (a tab close from a click during a
key resize starts outside a tick). TabStripMove.begin is CACurrentMediaTime()
again and MotionClock and FrameTick+Motion are feat's. Running intra-strip motion
on the frame clock (or the show time with a backward fill) goes to the S1
follow-up fix/frame-owner-s1b-first-frame, with a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es do not rise) (FRAME OWNER S1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ducers hold; a closing pane keeps its ground (FRAME OWNER S3)

FRAME OWNER REFACTOR stage S3, behind the Debug Settings switch
motion.frameOwner.contentPolicy (LayoutTunables.frameOwnerContentPolicy, section
Springs), DEFAULT ON. 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:
it goes, with the off path, after the first FEAT DOGFOOD BUILD gate passes with
it on. Off is exactly the path before this commit.

The rule: the shell color never shows inside a pane rect, in any frame.

Red (PaneGroundCoversPaneTests, f3de79c, step d1a161b5a9947b4f7bfbc297 at
22ff6b7, 7 issues): one key press H/L/K/J left up to 60 pt of the growing
pane's content area without ground for 25-26 frames, a held key 178 pt for 37
frames, a split close and a column close a 389 pt hole where the pane was.
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
(ground, padding) stayed at the start size while the surface already had its
final size (announceRest -> restAt -> holdSurface at the press), clipped by the
held host. A springing close hid the closed host in its first frame.

1. Containers follow; producers hold. PaneMotionHoldFollowing (CmuxNextDesign,
   beside PaneMotionHold): with PaneMotionHold.containersFollow the content area
   gives each following subview its bounds in every frame (no bounds-origin
   shift for it) and hands it the hold once; other subviews stay held as in S1.
   - TerminalHostView follows: its frame, ground, padding and scroller follow
     the pane. Only TerminalSurfaceView holds, at the size restAt chose (the
     final size, the one grid sent at the press: S3a), fixed at the pane edge
     that does not move (PaneMotionHold.producerFrame). No surface
     setFrameSize per frame: no grid, no reflow per frame; only its origin moves.
   - BrowserChromeView follows when its page can hold (a Chromium page shown
     plainly): toolbar and page area follow; the page keeps its size on the held
     edges through CEFTabContentView'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 S1. Agent chat and band mode keep the
     whole-view hold (S5/S6), as S1.
2. One policy. PaneHostView picks the hold per motion kind and hands it down:
   key resize (the K3 hold, now producer-only with the switch on), split, close
   and zoom springs (live; the provider's rule holds an agent chat), divider
   drags (TerminalDragTunables, unchanged), window live resize
   (ResizeCoordinator, unchanged). With containers following, heldPin reads the
   edges that do not move (not the content's anchor), and the area hears the
   hold in the key's turn, before announceRest, so the surface takes its final
   size at the edge that stays. The landing carries the final content (S3a's
   order: lay out the subtree, then notify). The terminal measures its rest size
   from the host size the hold began at (paneMotionBasisSize).
   The dead Debug setting motion.pane.holdContent is deleted: app pane hosts
   never read it (they get the provider's hold rule); one policy, one reader.
3. 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 (PaneContentAreaView.groundColor: the terminal host's ground, else the
   area's). ClosingPanes collapses it on its neighbours' spring: each edge a
   neighbour closes over rides that neighbour's facing edge (same spring, same
   velocity, so they stay together), the leading side keeps the divider gap,
   an edge no neighbour closes over stays; it lands with the panes and is torn
   down by releaseRetired (ScreenContentView.releaseClosing). A close taken back
   shows the same host (host(for:)) and springs on from where it showed.

perf-hot marks: PaneContentAreaView.showHeld, PaneHostView.layoutClip,
TerminalHostView.layout and followedSurfaceFrame, ClosingPanes.step,
presentClosingPanes (4 ms, motion-frame).

Tests (fleet, named in the PR body): PaneGroundCoversPaneTests green;
PaneContentPolicyTests (new: the latch, the anchor per side, one grid per
press, S3c ground/take-back/teardown); ON cases in
KeyboardResizeContentHoldTests and KeyboardResizeReflowAfterLandingTests
(their OFF cases kept); CloseTeardownAfterLandingTests off and on.

Area owners told in STATUS: PL-motion-core, PL-ghostty, PL-motion-cef.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e; the S3 cost bench (CMUX_PANE_BENCH) (FRAME OWNER S3)

The first fleet step at a87cf1f (bf4d74d232803acae4c2a0e2, aws-m4pro-4) stopped at
compile: TerminalHostView.surfaceSize is internal, and the two new ON cases imported
CmuxNextTerminal plainly. Now @testable, as PaneContentPolicyTests does.

PaneContentPolicyBench (CMUX_PANE_BENCH=1, release, never in CI): main-thread time per
display frame of 12 key presses and a close, 3 terminal panes, motion.frameOwner.contentPolicy
off vs on, interleaved in one process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nto fix/frame-owner-s3-content-policy

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift
…ea per frame; a container shown mid-hold hears the hold (FRAME OWNER S3)

Fleet step 039772103d3d798da2fdb78d (b46792a, aws-m4pro): PaneGroundCoversPaneTests
PASSED (the S3 reds, 4 tests / 7 cases); PaneContentPolicyTests 4 of 5; three of my new
checks were wrong, not the code:
- KeyboardResizeContentHoldTests / KeyboardResizeReflowAfterLandingTests ON cases
  counted host size changes per frame, but frames sit on the pixel grid, so the last
  frames of a spring change no size: they now check that each terminal host has its
  pane area's size in every frame.
- PaneContentPolicyTests' layer count read the root's CALayer sublayers, which an
  off-screen window does not build: it now counts the views' backing layers and checks
  that the closed pane's layers leave the tree.
KeyboardResizeOneGridReportTests' takeover case failed at its S1 precondition: fixed on
S1's branch (666aa5c), merged in 5cead39.

PaneContentAreaView: a container shown or added later in a following hold hears the
hold in its first frame (it kept its live path before), and PaneContentView+Parking
places a container that follows at the area's bounds (contentBounds(for:)), not at
the held rect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s rest size from the held size; followers hear the hold's end exactly once (FRAME OWNER S3)

Self-review of e79b747 found three defects in PaneContentAreaView's follower set:
- A container shown during a following hold (a tab switch) was placed at the
  area's live bounds (contentBounds(for:)) before it joined, so a terminal that
  joined mid-spring measured its rest size from the live size plus the press's
  whole change (restAt's basis): a wrong final grid. It now comes in at the held
  rect, as every view did in S1, and follows from the area's next pass; what it
  measured on its way in is from the held size (paneMotionBasisSize).
- A follower whose producer stopped holding mid-hold (followsPaneMotion false:
  DevTools docked) was dropped from the frame updates but kept its hold: it now
  keeps following until the hold ends (its producer lays out live).
- The release told every PaneMotionHoldFollowing subview the end, even ones never
  handed the hold; it now tells exactly the ones it handed.
MotionSample skips a closing host (it is not a live pane).

New case: PaneContentPolicyTests.aTerminalThatJoinsDuringAHoldFollowsAndRestsAtTheFinalSize
(a tab switch to another terminal at frame 6 of a key press).

Wide step 154ed71a87f947ca582a3333 (aws-m4pro-7, e79b747): 32 of 33 suites PASSED
(the S3, S1, S3a, K3, close, column-close, row-close, zoom, burst, terminal-grid, page
and parking suites); PaneMotionWorkTests gave no result: it runs only with
CMUX_PANE_BENCH=1 (the bench lane), not a red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s each frame; closing hosts stack under live ones; review fixes (FRAME OWNER S3)

Codex review of 6ea84b8 (three parts, no tools, pack in the prompt): FIX. Findings
checked and fixed:
- Compile error at 6ea84b8: PaneContentView+Parking called the removed
  contentBounds(for:) (two of my text edits overlapped). Parking is the base's again.
- Two closes in a row (Cmd-W twice, the second before the first landed): the first
  closing pane's side kept springing toward a pane that had closed too, and left the
  shell between them. ClosingPanes now has no springs of its own: in each frame a
  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 (min of the leading neighbours' trailing edges, max of the trailing
  neighbours' leading edges): several neighbours on one side, a neighbour that closed
  too (it reads that pane's closing rect, else where it last was), a retarget, all
  hold by construction. Neighbours only in the same space (strip or dock, scroll area,
  row column).
- Closing hosts stack under every live host of their rank (ensureStacking), and keep
  the rank they had at the close (a docked pane's corner rank included): a live pane
  that moves over a closing pane's place draws above its ground.
- A drag's end no longer ends a key hold's page hold (CEFTabContentView); a page that
  joins a chrome during a hold (a woken tab) gets the hold (BrowserChromeView keeps
  it); follow eligibility is the hold's own precondition (canStartPageHold: a sized,
  plain page in a window).
- A parked container shown during a hold (a tab switch to a parked terminal or page)
  joins in that frame's layout pass (PaneContentAreaView.followerDidShow and layout()),
  not at the area's next size change.
Not changed, by design: a page that cannot hold any more mid-hold (DevTools docked, a
side panel, a fit, hidden then shown) lays out live at the chrome's page area, as pages
do in split and close springs (never the shell); the PaneHostView init default for the
structural-hold rule is false (app hosts always pass the provider's rule; the deleted
tunable was false and no app host read it).

New cases (PaneContentPolicyTests): twoClosesInARowLeaveNoHole (coverage of the column
in every frame and closing hosts under live ones), aHeldKeySizesEachSurfaceOnlyInAPressTurn
(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).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… take-back without motion, panes nothing closes over, the prompt edge (FRAME OWNER S3)

Codex round 2 at ff2295a (pack in the prompt, no tools): part C (browser) SHIP;
parts A and B FIX, one finding each, both real:
- A press and its reverse in one key hold (Ctrl-Shift-H, then L before the landing):
  the reverse rests at the size the hold began at, so restSizeChange was nil and the
  content never heard it; the surface kept the first press's size and resized at the
  landing (a late grid). PaneHostView now announces a zero change when a following
  key hold's rest went back to its start size after another announcement
  (returnedToHeldSize; latched with the policy, OFF unchanged).
- A close taken back in a change that does not animate kept its partial closing
  frame: the restored frame now goes through place(_:at:animated:) (it snaps).
  Take-back velocity comes from the neighbour that places each side.
Claude review at 6ea84b8 (FIX), findings not already fixed in ff2295a:
- A pane nothing closes over (the right of two strip columns: only the camera moves)
  kept a full-size ground to the landing, then popped. retireClosing now takes the
  update's closing panes together; one whose sides ride no live neighbour on a spring,
  directly or through another closing pane, hides at once as before S3c.
- carryRowOffset moves closing panes of that row column with the live ones.
- A follower switched away and back takes the area's bounds (contentBounds(for:)), a
  new one still comes in at the held rect; hidden followers get no frame writes.
- The join test asserts exactly [final grid] (it accepted the e79b747 defect).
- The S1 switch is read through LayoutViewContext.followsTabStrip (tabStripOverride),
  and the S3 test rig pins both switches: the shared store leaked across suites.
- A closing pane's ground keeps the pane's rounded corners (applyChromeShape).
- Deviation from the approved note 2, flagged: a pane that grows down with its prompt
  in the lower half keeps its surface on the moving bottom edge (the terminal's own
  .bottom anchor), so the prompt stays in view; the surface is then taller than the
  pane, so it still covers it. Every other case keeps the still edge.

New cases: aPressAndItsReverseInOneHoldSendEachGridInItsTurn,
aCloseTakenBackWithoutMotionSnapsToItsPlace, aPaneNothingClosesOverHidesAtOnce.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d holds again (FRAME OWNER S3)

Codex round 3 at 75dbed3: parts A (core, terminal) and B (close host) SHIP; part C
(browser) FIX, one finding, real: a browser switched away and back during the same
key hold lost its page hold (CEFTabContentView.viewDidHide dropped it, and showing it
again did not ask for it), so the page laid out live for the rest of the spring. Hiding
now keeps the key hold asked for (dropHold(keepingKeyHold:)), and the container holds
its page again when it shows (resumePageHold, after it borrows the pane's page view);
the hold's end, leaving the window and a page that cannot hold still drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from its carried sides; a doc line's comment marker (FRAME OWNER S3)

Codex round 4 at f4d815e: part A SHIP; two findings, both real:
- CEFTabContentView+PaneMotion.swift: a wrapped doc line lost its /// marker (a
  compile error at f4d815e; my edit).
- A close taken back in a change that also moves the strip origin (a dock comes) read
  its neighbours' live frames inside reconcile's pane loop, where some were not
  carried yet (dictionary order): the restored frame could start off by the carry.
  ClosingPanes.remove now starts the frame from the pane's last presented sides, which
  carry() moved with the strip as reconcile moves the live frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Non-green checks at d67e03c, classified (none is caused by this PR):

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Claude review subagent (independent, read-only), re-review of the repair at 7827a1f (the same S3 diff as this head d67e03c except the one-sided close and two test fixes): FIX. This is the author's record of it, not a SHIP receipt; the review gate for this PR is the Codex receipt at the head (comment 6613789044).

Its earlier nine findings (at 6ea84b8) are verified fixed. Its new findings and what happens to each:

  1. P1, not a regression: a close of the last column of a strip whose camera moves back still shows the shell (the stillness decision runs before the camera is set). Refactor owner's decision: follow-up S3d, red test first, after this PR merges. Red test written (PaneCloseCameraGroundTests), on a local branch.
  2. P2, not a regression: a docked column's close hides at once (no neighbour in its own space). Follow-up.
  3. P2, rare: a closing pane in a horizontal scroll area is not clipped to the area window. Follow-up.
  4. P2: three repairs have no red test (a take-back with a dock appearing, the CEF page hold across hide/show, the parked-tab follower path). The other review-fix tests were run red before their fixes (step 472b3ffd0c9fa5fb7553a029, 3 of 5 red; 2 are guards). Follow-up.
  5. P2, test-only: TabStripView reads the S1 switch from the shared store; the S3 rig's per-view override does not reach it. The S1 switch deletion (frame-owner-s1b) removes it.

…wner-s3-content-policy

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/LayoutPaneContentProvider.swift
#	Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (codex): SHIP at df840ec

Codex (gpt-6.1-sol) via codex exec --skip-git-repo-check --sandbox read-only, ninth round, at this exact head: round 8's head d67e03c (SHIP on all three parts) with origin/feat-cmux-next f2199f2 merged in (feat deleted the S1 and S2 switches, #34195 and #34199; the resolution removes S3's reader of the S1 switch and keeps S3's own). The reviewer had no tools on this VM: each part's prompt held the diff against f2199f2, the changed source files in full at this head, the callees it had to read and the tests.

Part A: core and terminal host (PaneMotionHold, PaneContentAreaView, TerminalHostView, TerminalSurfaceView, PaneHostView, tests)

Review (codex): SHIP at df840ec

No concrete blocker or should-fix defect is demonstrated by the supplied code.

Notes (unverifiable; do not decide the verdict):

  1. TerminalSurfaceView.swift — geometry.finalSized, viewSized, motionLanded: TerminalGridPolicy is not shown, so exact grid deduplication at landing cannot be independently established. The supplied tests assert grid counts and final sizes, but no test results are provided.

  2. PaneContentPolicyTests.swift — held-key and reverse tests: repeated presses and reversal cover horizontal resizing. Vertical repeats, reversal with the bottom-prompt exception, and switch flips during a retargeted hold are not directly asserted. Sampled sizes also cannot detect intermediate resize calls within one layout pass.

  3. PaneContentAreaView.swift — follower lifecycle: the identity set provides one initial handoff, updates when the hold value changes, and one end on removal or release. The supplied tests do not directly count these callbacks or exercise reentrant follower callbacks. No concrete crash follows from the terminal implementation shown.

  4. PaneHostView.swift — screen.context.followsContentPolicy: the latch is visibly stored only when a new hold begins and reused through retargets. The context reader itself is absent, so its default and override wiring cannot be verified here. Close-switch latching and closing geometry are outside the supplied implementation.

Checked and found correct: unconditional content-area selection after S1 removal; S3 latching within PaneHostView; OFF-path whole-container holding; start-size rest calculations; still-edge producer placement and bottom-anchor exception; zero-change reverse announcement; follower joining, parking, removal and release bookkeeping; guarded frame writes and linear per-frame follower work; supplied single-press H/L/K/J and horizontal repeat/reverse assertions.

Part B: close host S3c (ClosingPanes, ScreenContentView+ClosingPanes, reconcile, stacking, PaneHostView+ClosingGround, tests)

Review (codex): SHIP at df840ec

No concrete blocker or should-fix is demonstrated by the supplied code.

Notes (unverifiable; do not affect the verdict):

  1. Merge resolution and hold latch: LayoutViewContext.followsContentPolicy retains S3’s per-view override. Closing reads it only in retireClosing; existing closing entries continue independently of subsequent switch changes. The key-hold initialization and unconditional content-area selection are not shown, so their merge resolution and per-hold latch cannot be verified here.

  2. Coverage tests: Rig.uncoveredWidth(of:) measures host rectangles along one horizontal line, ignoring padding, rounded corners, masks, ground opacity and vertical gaps. The tests assert useful geometric coverage for split closes, column closes and successive closes, but do not prove rendered coverage in every frame, several-neighbour configurations or a moving strip camera.

  3. Missing implementations/assertions: The supplied text omits applyChromeShape, the closing-ground hit-test implementation, overlay synchronization and row-offset carry call sites. Consequently, rounded ground, overlay exclusion, complete hit-test behavior and row carry cannot be fully verified. The take-back test excerpt also ends before its continuity assertions.

Checked and found correct in the shown code: neighbour min/max edge selection, successive-close dependencies, still-pane hiding, camera carry bookkeeping, same-host take-back and nonanimated placement, retirement cleanup call paths, retained stacking rank below live hosts of that rank, dock-report/motion-sample exclusion, and the close-policy OFF branch.

Part C: browser chrome (BrowserChromeView, CEFTabContentView page hold)

Review (codex): SHIP at df840ec

No concrete findings from the supplied code.

Notes (unverifiable; do not affect verdict):

  1. The switch reader and hold/close creation sites are absent. I cannot verify that contentPolicyOverride is honored, S3 is latched separately for every hold and close, or OFF preserves the complete old path. Here, PaneContentAreaView correctly uses the stored hold.containersFollow.
  2. Same-frame layout is requested on unhide: BrowserChromeView.viewDidUnhide() calls followerDidShow(), and the area’s layout hands over the hold and assigns its current bounds. The surrounding frame driver is absent, so completion before presentation cannot be established from this excerpt.
  3. Final producer layout before landing notification, actual Chromium presentation, and close coverage require code not supplied.

Checked and found correct: follow eligibility matches page-hold startup; producer anchoring uses the hold’s edges; drag end preserves key holds; chrome retains and reapplies the hold to attaching pages; parked followers rejoin through area layout; already-handed followers take current area bounds; this browser path has no remaining S1 switch dependency.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Base finding at the merged head df840ec (not caused by this PR): feat f2199f2 carries the red from 010ffb9: CloudTunnelExtension/PacketTunnelProvider.swift:50 "no member 'defaultPath'" (macOS 26.5 SDK). A Release app build or cmux-ci compile-check --app --release of any feat-based head fails on it, as the feat dogfood builds do. Fix: #34198 (ready-to-land). This PR touches no Cloud Tunnel file. The CmuxNext package suites at this head run on the fleet (they do not build the app target).

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Check classification at df840ec (adds to comment 6614290430):

  • cmux-next fast tier and changes: infrastructure, not code: "The job has exceeded the maximum execution time" (20 min and 5 min), then cancelled. changes is re-running on the same SHA; cmux-next fast tier re-runs when its workflow run finishes.
  • nightly-guards / workflow-guard-tests / release-notary (and its Guard status wrapper): base finding, red on unrelated open PRs too (comment 6614290430).
  • Executed tests at this head: the fleet step 0adbef272942c6dc2b4588da passed 32 of 32 CmuxNext suites, and the GUI proof 75ca2c19d2081d32cc91d723 passed 8 of 8 on a display worker.

@lawrencecchen
lawrencecchen merged commit a08c406 into feat-cmux-next Nov 3, 2026
215 of 221 checks passed
@lawrencecchen
lawrencecchen deleted the fix/frame-owner-s3-content-policy branch November 3, 2026 21:52
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…checks the hold's edge (FRAME OWNER S1B)

After feat's S3 (#34170) the held area rides the edge that stays, not the content's
anchor: a vertical grow DOWN of the upper pane keeps its top edge and holds on the top,
so green f0b72826b93702adcb9b69ad (ac2d27c) ran the old bottom case on the top edge and
saw no "no row" offer. The case now grows the LOWER pane up (its top edge moves; it
holds on the bottom by either rule) and requires that the area held on the bottom edge
at the switch, so it cannot pass vacuously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…rest size before the host moves; S3's tests take the grid by the first frame (FRAME OWNER S1B)

Green 96e5cad5ab8369ce052db01c at 6422d0b (feat with S3 #34170 merged in): 3 of 14
suites failed.
- KeyboardResizeOneGridReportTests: each surface took a live size in the press's first
  frame, then its final size. With S3 the key hold begins as the host's frame changes and
  the containers (terminal hosts) follow the pane in that same pass, so the surface
  measured the first frame's size before announceRest gave it the final one. The rest
  announcement of a key press now runs before the host moves (ScreenContentView
  applyPresentation, only while the screen holds content for a key resize): the surface
  takes its final size once, at the edge that stays, as it did when the key's turn did it.
- PaneContentPolicyTests and KeyboardResizeReflowAfterLandingTests (S3's) pinned the
  final grid and the surface size "in the key's turn". They now take them by the press's
  first frame (the contract the refactor owner approved for this PR) and count that
  frame as the press's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…holdContent takeover resizes held content once (FRAME OWNER S1B) (#34247)

* test(tabs): a key resize decides the tab row for the page it shows, not the pane (FRAME OWNER S1B, red)

FRAME OWNER REFACTOR S1B, the review low of #33874 at b75090d (PR comment
6610452584): the tab-row decision is kept per PANE, not per PAGE.
`PaneContentView.tabRow` is written at rest and read while the content area holds,
whatever page the pane shows by then; `paneMotionHoldArea` reads it at the hold's
start.

New file PaneTabRowPerPageHoldTests (the app's PaneContentView in p1 | p2, manual
120 Hz frames, the landing's after-commit release run by the test; a page double
that learns its tools' width from the first offer and draws them in its top row):
- (a) aChatThatReportsItsToolsDuringAKeyResizeDrawsItsRowOnlyInTheStrip: a chat
  offered the row before it reported its tools gets the area hold; it reports its
  tools two frames into a Ctrl-Shift-L spring. Expected here: red: the pane goes on
  offering it the row while it keeps the page below the strip, so the page draws its
  tools in its own top row, under the strip, until the landing.
- (b) aChatShownDuringAKeyResizeKeepsItsRowAndItsFrameUntilTheLanding: a terminal
  shows, a row-sharing chat is parked in the pane; two frames into the spring the
  chat's tab is selected. Expected here: red: the chat's frame is set to the held
  area under the strip, it is offered no row during the spring (its page leaves its
  one-row header) and gets the row again at the landing, laying out twice.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=PaneTabRowPerPageHoldTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tabs): (a) checks the decision the pane owns: no row change while the area holds, the row once at the landing (FRAME OWNER S1B, red)

Red run of the previous commit (step 25260dd011dc2d7037ac5c43, cmux9s-Mac-mini):
both cases failed as expected. (a): in frames 2-26 the chat drew its 28 pt row with
its top at 772, the strip's top at 800. (b): the chat heard 15 "no row" offers during
the spring and laid out 3 times from its switch to the landing.

(a) narrowed. A page that learns its tools in the spring shows them in its own top
row under the strip until the landing, as it does at rest until its report arrives.
Holding it whole needs to know that a report is coming: the pane cannot tell a chat
still learning from a page that has no tools (a New Tab page, an internal page), and
holding those whole would take S1's strip-follow from them. Only the page can say
it (its header answering each offer); that is the chat page's change, named in the
test doc. What the pane owns, and (a) checks now: the content area holds through the
whole spring (the strip follows the pane), the page hears no row change while it
holds (the latch offered the row on every pass), and the landing gives the row once.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=PaneTabRowPerPageHoldTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tabs): a key resize decides the tab row for the page it shows; while the area holds no page hears a row change (FRAME OWNER S1B)

The review low of #33874 (PR comment 6610452584): the key resize's tab-row decision
was kept per PANE (`PaneContentView.tabRow`, written at rest, read while the content
area held, whatever page the pane showed by then). Now:
- The hold's shape is decided for the page shown at the hold's start, at that width
  (`paneMotionHoldArea`, `PaneTabRowSharing.drawsInTabRow`): a page whose tools are in
  the row holds whole with its strip (S1's exception, S5/S6); any other content holds
  only its area. Same rule as S1, from the page itself instead of a latch.
- While the content area holds, the row is the strip's and nothing decides it again:
  no offer and no toolbar hole change reach any page (the latch offered the row, or no
  row, on every layout pass). The landing's layout decides it once.
- A page that shares the tab row and is shown during the hold (a tab switch to a
  parked chat) keeps its parked frame, held with the rest of the area (its row's part
  above the area, under the strip): before, its frame was set to the held area, it
  heard "no row" in every frame and the row again at the landing, laying out 3 times.
The per-pane latch is gone.

Not fixed here (test doc of PaneTabRowPerPageHoldTests): a page that learns its tools
during the spring shows them in its own top row under the strip until the landing, as
it does at rest until its report arrives. The pane cannot tell a chat still learning
from a page with no tools (a New Tab page, an internal page); only the page can, by
answering each offer: a change to the chat page.

No switch: a fix inside S1's own path (default since #33874). App lines: -1 net
(the rule's helper lives in CmuxNextDesign with the protocol).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): the key-resize hold's other ends: host taken out, Reduce Motion, split/zoom takeover, holdContent, content shown mid-hold, held hit testing (FRAME OWNER S1B)

FRAME OWNER REFACTOR S1B: the paths Codex's review of #33874 at b75090d (PR
comment 6612204462) named as untested. New file FrameOwnerS1HoldPathsTests (the
app's PaneContentView in each pane, manual 120 Hz frames, the landing's
after-commit release run by the test):
- aHostTakenOutOfTheScreenLetsItsAreaGo (mid-spring, and after the landing frame
  while its release waits for the commit): the area lets go, the content heard the
  landing at most once and sits at its area's size.
- reduceMotionDuringAKeySpringLandsItAndLetsTheAreasGo: Reduce Motion on mid-spring
  lands it in the next frame (landMotion, settleMotion); every area lets go; each
  content took its final size once and heard the landing once; strips on panes.
- aSplitOrZoomTakingOverAKeySpringLetsTheAreasGo (split, zoom): the takeover ends
  every key hold at once; strips on their panes in every frame of the new spring.
- aSplitTakingOverWithHoldContentKeepsTheKeyHoldsSizeToTheLanding: with
  motion.pane.holdContent on (the provider's hook) the content keeps the key hold's
  size through the split and takes its rest size once. Expected here: red. In
  endKeyResizeHold the area lets go (syncHeldArea, inside layoutClip) before the pane
  content view lays out at the size the whole-view hold now keeps, so the content
  takes the area's current size, then the held size again in the same turn.
- contentShownDuringAKeySpringJoinsTheHoldAndTakesItsSizeOnceAtTheRelease.
- heldContentTakesClicksAndConvertsPointsWhereItShows (4 edge pairs, a flipped
  descendant): held edges, point round trips, a click reaches the view drawn there.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=FrameOwnerS1HoldPathsTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): a key hold's content area takes the frame it lets go at before it lets go: held content changes size once (FRAME OWNER S1B)

FrameOwnerS1HoldPathsTests.aSplitTakingOverWithHoldContentKeepsTheKeyHoldsSizeToTheLanding
(Codex's untested path "takeover with motion.pane.holdContent ON"): when a split takes
a key spring over with motion.pane.holdContent on, `endKeyResizeHold` keeps the key
hold's size for the whole view, and `layoutClip` gave the pane content view that frame,
but `syncHeldArea` let the content area go first, at the area's last spring frame:
the terminal (or page) took that size, then the held size again in the same turn (two
surface resizes for one takeover, three sizes by the landing).

`syncHeldArea`, when the hold has ended: the content view lays out at the frame
`layoutClip` just gave it while its area still holds (the area takes its final frame,
its subviews keep theirs), then the area lets go; held content changes size once,
to the area's final size, or not at all when that is its held size. Landings and the
default takeover (live resize) end exactly as before: there the content view already
had its frame. Once per hold end, not per frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): the hold-paths rig holds its after-commit queue weakly (FRAME OWNER S1B)

Red run 35ad952006d3166692905bdd (433e605, aws-m4pro-9) crashed in teardown, not on
an expectation: aHostTakenOutOfTheScreenLetsItsAreaGo(afterTheLanding: false) ends with
p1 mid-spring, closing the window settles it, and the root asked for after-commit work
through the rig's unowned self after the rig was gone. The rig now holds it weakly.
The red base for b3b94ca is rerun at the scratch commit 9cb9290 (433e605 plus
this change, branch fix/frame-owner-s1b-red-1d).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): the holdContent takeover case counts sizes, not frames: held to rest once (FRAME OWNER S1B)

Green run a21fba4f17e875cf7e372e21 at d421474 (aws-m4pro-9): 12 of 13 suites passed;
in FrameOwnerS1HoldPathsTests only the holdContent takeover case failed, on its frame
check: in the split's last frames (32-38) p2's content took its rest size, because the
holdContent hold ends when the pane reaches its rest geometry (PaneHostView.contentFrame:
a host at its rest place and size holds nothing), before the spring flag clears. That is
one layout at the rest size, as the hold intends. The case now checks what the defect
was: the takeover resizes nothing, and the content goes from its held size to its rest
size once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tabs): a chat shown mid-hold on a bottom-held growing area draws no row under the strip (FRAME OWNER S1B, red; Codex review of #34247)

Codex's review of #34247 at 7d105c5 (FIX, finding 1, should-fix): install keeps a
row-sharing page's parked frame during any area hold. On a bottom-held area that grows
(a vertical grow whose content at the hold start is a terminal with its prompt on its
last row) the area grows above the held content, and the parked chat, framed the strip's
height above the area, shows its own tab-row tools inside the area, under the strip,
with its old positive row offer.

PaneTabRowPerPageHoldTests (b) now runs two presses: the horizontal grow (top-held:
no row change, one layout, as before) and a vertical grow of p1 over p2 held on the
bottom edge. In every frame the chat draws its row only in the strip's row; held on the
bottom edge it hears one "no row" when shown and lays out at most twice (the switch and
the landing). Expected here: red for the bottom-held case (the parked frame's row shows
under the strip as the area grows).

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=PaneTabRowPerPageHoldTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tabs): a row-sharing chat shown during a bottom-held key hold joins the held rect without its row (FRAME OWNER S1B; Codex review of #34247)

Codex's review of #34247 at 7d105c5 (finding 1): install kept a row-sharing page's
parked frame during ANY area hold. On a bottom-held area that grows, the area grows above
the held content and the chat's own row showed inside it, under the strip.

install during a hold now depends on the hold's edge:
- top-held (the row stays above the area): as before, the chat keeps its parked frame
  and hears no row change; the landing gives it the row and its rest frame once.
- bottom-held: the chat is told it has no row (setTabRow 0, once) and joins the held rect
  (contentBounds, autoresizing), held with the rest; the landing gives it the row and its
  rest frame once.
parkedFrame reuses PaneTabRowSharing.drawsInTabRow (the same rule as the hold's shape).
App lines: 0 net against the base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tabs): the bottom-held chat shown mid-hold takes its rest frame once, at the landing (FRAME OWNER S1B)

Green run 03647f52d87c0c5d40e86e2a at 875c0ae (aws-m4pro-5): 12 of 13 suites passed;
the bottom-held case of PaneTabRowPerPageHoldTests (b) drew no row under the strip and
heard one "no row", but laid out 3 times: the switch (the held rect), the area's release
(the area under the strip, autoresizing), and the landing's layout (the whole pane, with
the row). The chat now joins the held rect with no autoresizing, so the release leaves
it and the landing's layout sizes it once (and gives it its autoresizing back). App lines:
0 net against the base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tabs): the bottom-held chat case presses the lower pane up, and checks the hold's edge (FRAME OWNER S1B)

After feat's S3 (#34170) the held area rides the edge that stays, not the content's
anchor: a vertical grow DOWN of the upper pane keeps its top edge and holds on the top,
so green f0b72826b93702adcb9b69ad (ac2d27c) ran the old bottom case on the top edge and
saw no "no row" offer. The case now grows the LOWER pane up (its top edge moves; it
holds on the bottom by either rule) and requires that the area held on the bottom edge
at the switch, so it cannot pass vacuously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tabs): a row-sharing chat moved in from another pane during a key hold draws no row under the strip (FRAME OWNER S1B, red; Codex review of #34247)

Codex's review of #34247 at c997a7d (FIX, should-fix): a row-sharing page that comes
from ANOTHER pane during a hold takes install's early branch: it is put at the held rect
with autoresizing and keeps the other pane's positive row offer, so its tools draw in the
area under the strip, and no offer reaches it until the release.

PaneTabRowPerPageHoldTests (c): a chat sharing p2's row moves into p1 two frames into a
key hold of p1. In no frame does it draw its row off p1's strip row; it hears one "no
row" when it arrives; the landing gives it p1's row once. Expected here: red.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=PaneTabRowPerPageHoldTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tabs): a row-sharing chat that arrives from another pane during a key hold joins rowless (FRAME OWNER S1B; Codex review of #34247)

install during a key hold: a row-sharing page that comes from another pane (a moved tab)
is told it has no row and joins the held rect with no autoresizing, as a bottom-held
local one does: its tools no longer draw under the strip with the other pane's offer, and
the landing's layout gives it this pane's row and its rest frame once. Only a local page
on a top-held hold keeps its parked frame (its row above the area). App lines: 0 net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…trailing, bottom) while its new size waits (red)

S4 ON PR defect (b), found by the Codex review at 15c8848. In a key
resize the host follows its pane (FRAME OWNER S3, #34170) and gives the
surface its size on the hold's still edges (PaneMotionHold.producerFrame).
With motion.frameOwner.terminals on, while a new size waits for its frame,
place() kept the old frame on the wanted frame's leading edge, and on its
top edge unless the surface hold said bottom. So with a trailing hold the
kept frame's trailing edge moved off the still edge (by the size change), and
with a bottom hold its bottom edge did, until the new size showed.

aKeptFrameStaysOnTheEdgesItsHoldNames: a trailing hold with a narrower final
size, then a bottom hold with a shorter one. While each size waits, the kept
frame keeps the rest size on the still edges; the landed frame is the
producer frame.

Red command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=TerminalSurfaceCommitSizeTests
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…, and the strip's last column closes on the camera (FRAME OWNER S3d)

Two follow-ups of S3c (#34170), red first (PaneCloseCameraGroundTests, PaneCloseKeptHandleTests).

1. The gap that shows is the kept handle's. A closing pane used to keep the gap only on its
   leading side (when its trailing side had neighbours too). Where the handle that stays is on
   its trailing or bottom side (a split pane's right sibling closing, a column's right split pane,
   the pane above a kept divider, the strip's last column), the closing ground covered the gap
   under that handle, and the shell showed beside the gap whose handle had gone: the handle was
   off its gap by up to the closing pane's width. Now each side looks up the handle in its gap in
   the layout the pane left (split dividers, column edges, row edges, in the pane's own space) and
   keeps the gap if the new layout keeps that handle; where the handle goes it covers the gap. The
   pane still rests at zero size where its neighbours meet (they meet one kept gap apart).

2. The strip's last column. Closing it with two or more columns left moves no pane: only the
   strip camera moves back. S3c hid such a pane at once (nothing closes over it), so its place
   showed the shell while the camera moved (film P12). A closing pane now waits for the camera
   the change sets (ScreenContentView.settleClosing, in syncScroll before the change's first
   frame): a side with no neighbour, opposite one with neighbours, collapses on the share of its
   space's camera travel (the strip's, or its row column's scroll) when no neighbour moves, so the
   pane rests at zero size when the camera does. A pane that nothing moves over still hides at
   once, as before; with nothing moving at all the retired hosts still go in the change's turn.

No new owner, timer or hold: the closing pane's rect stays a function of its neighbours' springs
and its space's camera, read each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…; its first frame lays out once (FRAME OWNER S1B) (#34380)

* test(terminal): a key resize's own turn resizes no terminal surface; the PTY still gets its final grid there (FRAME OWNER S1B for S4, red)

FRAME OWNER REFACTOR S1B: the first-frame lag of a key resize is not a tab-strip
defect. In the films (base c7cef08 and S1 07f254a, cmux18) the whole window's commit of
the first frame tick after the key misses its display: 0 changed pixels on every scan
line in that frame, the next frame on time. Timed round s1b-keyturn-24 on FEAT DOGFOOD
BUILD e0e34bd6 (feat d9ef49f, cmux18): the key's turn is 5.35-8.24 ms to its commit
(debug.action_budget, resizePane*), the H press's first frame missed again (frame 240),
and the held-key sample puts the turn's work under PaneResize.press -> syncWithModel ->
applyPresentation -> PaneHostView.announceRest -> TerminalSurfaceView.restAt -> setFrame
-> updateSurfaceSize -> ghostty_surface_set_size (a reflow on main), beside
reportFinalGrid -> ResizeCoordinator.submitFinal.

New file KeyResizeKeyTurnSurfaceTests (the one-grid rig: real terminals in the app's
pane content): Ctrl-Shift-H, L, K, J on p2; in the key's turn no terminal surface takes
a new size, and each PTY still gets its final grid in that turn (RESIZE KEYS); after the
landing each surface rests at its host's size and sent no other grid. Expected here:
red (restAt resizes the surface in the key's turn). The fix is S4's (terminals
ready-or-keep: the size request goes to a frame tick).

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=KeyResizeKeyTurnSurfaceTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): a resize press presents nothing and sizes no surface before its first frame; the first tick presents once and sends each final grid (FRAME OWNER S1B, red)

The refactor owner's decision (round s1b-keyturn2-18, FEAT DOGFOOD BUILD e0e34bd6,
cmux18, 2x): for each column key the first frame tick comes one vsync late (key turn
7.7 / 8.2 ms to its commit, then the first tick 7.3 / 8.9 ms of main time). S1B owns the
key turn: a resize press changes the model and asks for a frame; no layout, no
applyPresentation, no announceRest, no surface size in its own turn. The first tick lays
out once.

KeyResizeKeyTurnSurfaceTests, revised (the previous commit pinned the old contract, the
final grid in the key's turn; red run ff405d72dcc348030e5ac3a6 showed both surfaces
resized in the key's turn):
- aKeyPressResizesNoTerminalSurfaceBeforeItsFirstFrame (H, L, K, J; real terminals):
  from the key's turn up to the first frame (the queued main turns drained, no clock)
  no surface resizes and no grid is sent; the first frame tick sends each terminal's
  final grid once; after the landing nothing else; each surface rests at its host's size.
  The RESIZE KEYS contract moves from "in the key's turn" to "no later than the first
  tick" (approved by the refactor owner): one grid per press either way.
- aKeyPressPresentsNothingUntilItsFirstFrameWhichMovesThePanes (H, L, K, J; the app's
  pane content): no presentation pass, no host frame change and no content layout before
  the first frame; the first frame presents once and moves the panes.
Expected here: red (the key's turn presents once and resizes the surfaces).

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=KeyResizeKeyTurnSurfaceTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): a keyboard resize press presents nothing in its own turn; its first frame tick lays out once (FRAME OWNER S1B)

The refactor owner's decision (round s1b-keyturn2-18, FEAT DOGFOOD BUILD e0e34bd6,
cmux18, 2x): for each column key the first frame tick came one vsync late, because the
key's turn (7.7 / 8.2 ms to its commit) synced AND presented the layout, then the first
tick (7.3 / 8.9 ms of main time) presented again. In the key's turn the presentation
moved nothing on screen (the springs had not stepped) but announced the rest sizes to the
content: each terminal surface took its rest size (ghostty_surface_set_size) and sent its
final grid; the overlay synced and Core Animation committed all of it.

PaneResize.press now syncs the layout with LayoutRootView.syncWithModelForNextFrame: the
springs get their targets in the key's turn (the next display frame still moves:
KeyboardResizeFirstFrameTests), with the presentations and the overlay sync held
(LayoutViewContext.holdsPresentations, S2's frame-input mechanism); a press that snaps
(Reduce Motion, pane animations off) asks for a frame, whose presentWaiting shows it. The
first tick presents once: hosts move, the key hold begins, each content hears its rest
size and each terminal sends its one final grid there.

Contract change (approved by the refactor owner): RESIZE KEYS' "one final grid at the
press" is now "one final grid, no later than the press's first frame tick" (about one
refresh later than the key's turn, still one SIGWINCH per press, before any motion frame
shows). KeyboardResizeOneGridReportTests takes the grids after that first tick.

Red: step 76f88cfece89dc7a00b7e876 at 67f0a67 (aws-m4pro-1) failed as expected: the key's
turn presented once (passes 3 -> 4), laid out both panes' content, resized both terminal
surfaces and sent their grids; the first frame then presented a second time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): a parked or windowless root syncs a resize press at once, as before (FRAME OWNER S1B)

A root that runs no frames (parked, hidden, out of its window: its driver is detached)
would keep the press's presentation waiting for a tick that never comes. It syncs and
presents in the press's turn as before; only a live root defers to its next frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): the key-turn layout count starts from a quiescent rig (FRAME OWNER S1B)

Green run ce59d01a7e75d952b2e37c42 at 0ee0b5b (aws-m4pro-1): 12 of 14 suites passed
(PaneMotionWorkTests is env-gated by CMUX_PANE_BENCH, no result). In
KeyResizeKeyTurnSurfaceTests only the content-layout count failed: each pane's content
laid out once in the turns after the press, the same count as in the red run
76f88cfece89dc7a00b7e876, while the presentation passes and the host frames held (those
checks passed). The count came from the rig, not the press: the first await after the
rig is built runs each pane content's first observation turns, which mark it for layout.
The test now drains those turns and lays the window out before it takes its baseline.
The red base is re-run at a scratch commit (67f0a67 plus this change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): with S3 merged, a key press's first frame announces the rest size before the host moves; S3's tests take the grid by the first frame (FRAME OWNER S1B)

Green 96e5cad5ab8369ce052db01c at 6422d0b (feat with S3 #34170 merged in): 3 of 14
suites failed.
- KeyboardResizeOneGridReportTests: each surface took a live size in the press's first
  frame, then its final size. With S3 the key hold begins as the host's frame changes and
  the containers (terminal hosts) follow the pane in that same pass, so the surface
  measured the first frame's size before announceRest gave it the final one. The rest
  announcement of a key press now runs before the host moves (ScreenContentView
  applyPresentation, only while the screen holds content for a key resize): the surface
  takes its final size once, at the edge that stays, as it did when the key's turn did it.
- PaneContentPolicyTests and KeyboardResizeReflowAfterLandingTests (S3's) pinned the
  final grid and the surface size "in the key's turn". They now take them by the press's
  first frame (the contract the refactor owner approved for this PR) and count that
  frame as the press's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): a late first tick keeps the held surface on the edge that stays; presses before one tick coalesce (FRAME OWNER S1B, red; Codex review of #34380)

Codex's review of #34380 at 545841e (FIX):
1. The key hold now begins in the press's first tick, and the hold's edges were picked
   from the rest offset of the STEPPED frame against the host's previous size: a first
   step past half the travel (a late tick, up to catchUpLimit 0.1 s) picks the moving
   edge. New KeyResizeFirstTickPinTests (a): Ctrl-Shift-H on the right pane with a 0.1 s
   first step (and 1/120 s as the guard): in every frame its surface keeps its place
   against the right edge, which stays. Expected here: red for 0.1 s.
2. Presses before one tick: the second press's targets replace the first's before any
   rest size is announced. The contract this PR takes (input coalesces to the frame, as
   Chrome does): (b) two presses before one tick send each terminal one grid, the final
   one; a press and its reverse send none and resize no surface. Expected here: green
   (it pins the contract).

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=KeyResizeFirstTickPinTests
--env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red --ref <this commit>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(layout): a key hold that begins in a late first tick picks its edges from the frame it shows (FRAME OWNER S1B; Codex review of #34380)

Codex's review of #34380 at 545841e (finding 1): the key hold now begins in the press's
first frame tick, after that tick stepped the springs. The host picks the edges its held
content rides from its rest offset against its frame (PaneHostView.contentFrame), but the
offset was already the stepped frame's while the frame was still the previous one: a
first step past half the travel (a late tick) picked the moving edge. applyPresentation now
gives the host its rest offset against the frame it shows when the key hold begins, flips
the spring flag (the hold starts and picks its edges), announces the rest, then sets the
stepped frame's offset before the frame moves, as before. Other springs are unchanged.

Finding 2 (presses before one tick): the PR's contract is that input coalesces to the frame
(KeyResizeFirstTickPinTests (b): one final grid for two presses, none for a press and its
reverse); stated in the PR body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): presses inside one frame keep every step (two presses move the edge two steps; a press and its reverse leave it and send no grid)

FRAME OWNER REFACTOR S1B, #34380's coalescing contract: only the PTY grid
report coalesces to the frame; no press is lost. Guard test (the behavior
exists); its mutation run and green run are in the PR body.

* test(layout): the coalescing guard compiles (no throwing call in an expectation's message)

* test(layout): a key press that starts no spring moves its pane in the key's turn (FRAME OWNER S1B, red)

Under Reduce Motion or with pane animations off, a resize press snaps: no
frame tick moves it, so the key's turn must present it, as before #34380.
Found by TerminalSurfaceCommitSizeTests.aReduceMotionKeyResizeNeverHoldsTheMainThreadForTheTerminals,
red on #34380's head b89a454 (step 371efb4af257d839a96202ef: the press
moved nothing) and green on its base de82e16 (step 7487a3eaf2de6c92df4831e4).

New file KeyResizeReduceMotionKeyTurnTests (manual driver, no tick): after
each press p2's host moved and no presentation waits. Expected here: red.

* fix(layout): a key press that starts no spring presents in the key's turn (FRAME OWNER S1B)

syncWithModelForNextFrame held every screen's presentation for the next frame
tick and asked the driver to start. Under Reduce Motion or with pane
animations off, the press's frames snap: no spring keeps the driver ticking,
so nothing ever presented the snap and the press moved nothing on screen.
Now a waiting screen with no motion presents in the key's turn (and the
overlay syncs once); only a screen on a spring waits for the tick.

Red: KeyResizeReduceMotionKeyTurnTests at 4ba728f (step
5e56fbf1b7846a3d661c5c43). Found by TerminalSurfaceCommitSizeTests
.aReduceMotionKeyResizeNeverHoldsTheMainThreadForTheTerminals (red at
b89a454, step 371efb4af257d839a96202ef; green on the base de82e16,
step 7487a3eaf2de6c92df4831e4). Green: same commands at this commit, in the PR.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
… visible gap; the last column and the last row close without showing the window background (FRAME OWNER S3d) (#35140)

* test(pane motion): closing the last column while the camera moves back shows no shell (FRAME OWNER S3c, red)

The Claude re-review of #34170 at 7827a1f (P1, not a regression): a close of the
last column of a strip whose camera moves back shows the shell where the column was
(film P12: the close of a new column, about 700 -> 10 px over 22 frames). No pane
frame moves in reconcile, only the camera, which syncScroll sets after it: the
closing pane is hidden as one nothing closes over, and releaseRetired tears it down
before the camera starts.

New file PaneCloseCameraGroundTests: a full-width column and a 2/3 one, the camera
on the right one, Cmd-W on it. In every frame the window is covered by pane hosts
beyond the gaps at rest, and the closing pane's last frame shows no ground for the
landing to take away. Expected red at this commit; run on the fleet with --red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): a resize handle that stays shows in the middle of its gap in every frame of a close (FRAME OWNER S3d, red)

R-HARNESS's divider check on the refactor owner's films: during a close, a handle's rect was
up to 15 device px off the middle of the gap between its two panes, so a press on it could miss
by 7 pt. With S3 on, a closing pane shows its ground on its place; the gap a person sees beside
it must be the one whose handle stays.

PaneCloseKeptHandleTests closes a pane and checks, in the close's first frame, each spring frame
and at rest, that along the line through the kept handle's centre the run no pane host covers
(live, or closing with its ground) holds the centre, centred within a point:
- the divider on the closing pane's trailing side (p1 | p2 | p3 made by splitting p1 of
  p1 | p3, close p2): red now, the closing ground covers that gap;
- the divider below the closing pane (p1 | p2 over p3, close p2): red now;
- the edge of a column whose right split pane closes: red now;
- the edge before the strip's last column while the camera moves back: red now (the
  closing pane hides at once and the closed place shows the shell);
- guards, green now: the films' px-close-pane-3 (the divider on the leading side) and
  px-close-column-c3 (the edge before a closing middle column).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the strip's last-column close keeps two columns, so its camera springs back (FRAME OWNER S3d, red)

Red step ef256026fc9d27421a89b2a0 at 9c25336: the two camera cases closed the second of two
columns. A strip left with one column becomes a split screen (LayoutModel.removingColumn):
there is no camera to move back and no column edge after the close, and the remaining pane
slides in on its own spring (S3c already covers that close). So PaneCloseCameraGroundTests
stopped at its SETUP check (the camera rests at once) and the kept-handle case found no
handle.

Both now use three half-width columns with the camera at the end and close the third: two
columns stay, no pane frame moves, only the strip camera springs back (as
ColumnCloseScrollMotionTests' columns(3)). The kept handle is the edge of the second column.

The other results of that step stay as the red evidence: 3 cases red as expected (the closing
ground covers the kept divider's gap on the trailing side, below, and beside a column edge),
2 guards green (the films' px-close-pane-3 and px-close-column-c3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane keeps the gap where its handle stays, and the strip's last column closes on the camera (FRAME OWNER S3d)

Two follow-ups of S3c (#34170), red first (PaneCloseCameraGroundTests, PaneCloseKeptHandleTests).

1. The gap that shows is the kept handle's. A closing pane used to keep the gap only on its
   leading side (when its trailing side had neighbours too). Where the handle that stays is on
   its trailing or bottom side (a split pane's right sibling closing, a column's right split pane,
   the pane above a kept divider, the strip's last column), the closing ground covered the gap
   under that handle, and the shell showed beside the gap whose handle had gone: the handle was
   off its gap by up to the closing pane's width. Now each side looks up the handle in its gap in
   the layout the pane left (split dividers, column edges, row edges, in the pane's own space) and
   keeps the gap if the new layout keeps that handle; where the handle goes it covers the gap. The
   pane still rests at zero size where its neighbours meet (they meet one kept gap apart).

2. The strip's last column. Closing it with two or more columns left moves no pane: only the
   strip camera moves back. S3c hid such a pane at once (nothing closes over it), so its place
   showed the shell while the camera moved (film P12). A closing pane now waits for the camera
   the change sets (ScreenContentView.settleClosing, in syncScroll before the change's first
   frame): a side with no neighbour, opposite one with neighbours, collapses on the share of its
   space's camera travel (the strip's, or its row column's scroll) when no neighbour moves, so the
   pane rests at zero size when the camera does. A pane that nothing moves over still hides at
   once, as before; with nothing moving at all the retired hosts still go in the change's turn.

No new owner, timer or hold: the closing pane's rect stays a function of its neighbours' springs
and its space's camera, read each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the last column's close shifts the strip on its carried panes; the last row of a scrolling column closes on its scroll (FRAME OWNER S3d, red)

Red step ca6953a3b97f1ff22fa99cdf at e8878b7: the column case still stopped at its SETUP
check. A column's removal clamps the strip camera to the new end at once (ColumnScrollReducer:
"the view carries the panes horizontally from where they showed"), so no strip camera springs
back: the neighbour's frame is carried and springs from where it showed, and starts moving
only in syncScroll (settleCarried), after reconcile decided under S3 that nothing moves beside
the closing pane. The kept-handle case at that step was red for the right reason (the handle
292 pt off the middle of the closed place's shell, 610...1200).

- PaneCloseCameraGroundTests: the column case checks that the close moves the strip (not that
  the camera springs) and reuses ColumnCloseScrollMotionTests.columns(3). New case: one column
  of four 400-permille rows scrolled to the fourth, Cmd-W on it; three rows still overflow, so
  the row column's scroll springs back (a row camera keeps its spring) and no pane frame moves.
  Both check every frame along the line across the closed place and the ground left for the
  landing.
- PaneCloseKeptHandleTests: the camera case reuses columns(3) and is named for the strip's shift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): only a row column's scroll collapses a closing pane; the strip's shift is its carried panes' (FRAME OWNER S3d)

The strip camera never springs back for a close: a column's removal clamps it to the new end at
once and the view carries the panes from where they showed (ColumnScrollReducer), so the last
column's neighbour moves and its closing pane collapses on that neighbour's travel. The strip
camera share of 447bb3a had no case to serve: it is gone. What the strip's last column
needed is the other part of that commit: which closing panes nothing moves over is decided once
the change's cameras are set (settleClosing, after syncScroll's settleCarried), when the carried
neighbour already springs.

A row camera keeps its spring: closing the last row of a column whose rows still overflow moves
no pane, only the row column's scroll. That travel (ClosingPanes.Entry.rowTravel) collapses the
free side of a closing pane whose other y side rides a neighbour that does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): two last-row closes in a row leave no hole (FRAME OWNER S3d, red)

Codex review of 447bb3a (P1): a closing pane keeps the scroll travel it took at its close,
so a later change that takes the row scroll to another target leaves its collapse stale. The
same case shows a second hole: the first closing pane rides the second one, and closing panes
are placed in the order they closed, so the first reads where the second last was while the
second's free side moves away (the shell between them).

PaneCloseCameraGroundTests.twoLastRowClosesInARowLeaveNoHole: five 400-permille rows scrolled
to the fifth, the fifth closes, and six frames later the fourth (the row scroll takes another
target). Every frame along the column's middle shows no more shell than the rest gaps, and the
closing grounds leave under a point for the landing. The check closes a list of panes, and the
ground left for the landing is what only closing grounds cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane follows the row scroll's new target and rides a later close where it shows (FRAME OWNER S3d)

Red first: PaneCloseCameraGroundTests.twoLastRowClosesInARowLeaveNoHole.

- When its row column's scroll takes another target (a later change), a closing pane's travel
  starts again from where the scroll and its y sides show now (ClosingPanes.rects), so it still
  rests at zero size when the scroll lands; with no travel left it rests at once. The scroll's
  target is read every frame with its value (ScreenContentView.rowScrollTravel).
- Closing panes are placed from the last to close back to the first. A pane borders only panes
  live at its close, so no closing pane rides one that closed before it; one that closed earlier
  now reads where the later one shows in this frame instead of where it last was. The first of
  two closes in a row no longer leaves the shell between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): a second last-row close that makes the rows fit leaves no ground (FRAME OWNER S3d, red)

Codex review of 02de7be (P1): close the fourth of four 400-permille rows, then the third
before the first lands. The two rows left fit, so the row scroll goes and the second row
springs to its rest. The fourth closing pane rides the third, a closing pane, so it has no
rest to collapse toward (a rest is read from live neighbours only) and no scroll travel left:
its free side freezes and its ground stays for the landing to take away in one frame.

PaneCloseCameraGroundTests.aSecondLastRowCloseThatMakesTheRowsFitLeavesNoGround checks every
frame along the column's middle and the ground only closing panes cover before the landing.
Red step 03270980560f5bd06b1d21bc at 7fdf9f7 already showed the first two-closes case red
for its reason (up to 109 pt of shell between the two closing panes, frames 7 on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane that rides another closing pane rests where that one rests (FRAME OWNER S3d)

Red first: PaneCloseCameraGroundTests.aSecondLastRowCloseThatMakesTheRowsFitLeavesNoGround
(Codex review of 02de7be, P1).

- ClosingPanes.rects keeps, for each pane it places, where it rests (zero size where its sides
  meet: a side on its neighbours' rest, a free side on the rest of the side opposite). A side
  that rides a closing pane (one that closed after it, placed earlier in the pass) reads that
  rest, as a side on a live pane reads its target frame. The free side opposite it then
  collapses toward where that pane rests, and still reaches zero size when the row scroll that
  drove it is gone (the rows fit).
- A free side's travel now remembers its end (Side.end). When the end moves (a later change, a
  neighbour that closed too) or the row scroll takes another target, the travel starts again from
  where both sides and the scroll show now: the free side never jumps when its driver changes. A
  coordinate change (carry, shiftY) moves the end with the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the motion trace reports a closing pane's ground apart from the live panes (FRAME OWNER S3d, red)

R-HARNESS pair C judges each divider rect against the gap between the panes beside it, and its
judge reads closing panes as rows of the trace ("any pane row counts, a closing one too"). The
trace has none: the sampler skips every host that shows a closing ground (S3, so the live-pane
judges do not take a ground for a pane whose content is gone). So on an S3 build the judge
sees the closed place as the gap and reports the kept divider off its middle, as on a base
build.

PaneCloseKeptHandleTests.theTraceReportsAClosingPaneApartFromTheLivePanes: in a close's first
frame, the sample lists the closing pane under `closing` with the rect its ground shows at, and
not under `panes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): the motion trace lists closing grounds under `closing` (FRAME OWNER S3d)

Red first: PaneCloseKeptHandleTests.theTraceReportsAClosingPaneApartFromTheLivePanes.

ScreenContentView.motionSample (DEBUG and bench hooks only) now reports each shown closing pane's
ground as `[pane, x, y, width, height, alpha, z]` under a new `closing` key, apart from `panes`,
whose rows also say how live content is laid out (a closing ground lays out none). A trace judge
that centres a handle on the gap between its panes can now read the closing ground beside it
(R-HARNESS pair C); the live-pane judges read `panes` as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): two last-column closes in a row leave no ground past the strip (FRAME OWNER S3d, red for d369376)

The red for d369376 (a closing pane that rides another closing pane rests where that one
rests). af48fab's row case passed without that fix (step cadeb2ac05f197c36580c860): rows
that fit fill their column, so the frozen free edge of the old code was already at its rest.
That case stays as a guard.

PaneCloseCameraGroundTests.twoLastColumnClosesInARowLeaveNoGroundPastTheStrip: four 0.3-wide
columns (new columns keep their width), the camera at the end on the fourth; the fourth closes,
and six frames later the third. The strip fits after the first close and has an empty end
after the second. Without the fix the fourth closing pane rides the third with no rest to
collapse toward, so its free edge freezes in the strip's empty end, and its ground there goes
only with the landing. Every frame along the window's middle and the ground left for the
landing are checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): read a closing neighbour's edge through a closure literal (release build)

The release build of d369376 refused ClosingPanes.rects (bench step 8bba1189a5d3b22e2fcbde55,
ClosingPanes.swift:175 and :178): "sending 'edge' risks causing data races": `shown[$0].map(edge)`
and `rests[$0].map(edge)` hand the task-isolated parameter `edge` to `map` from a main-actor
closure. Both now call it in a closure literal (`map { edge($0) }`), as the live-frame reads
beside them already do. No behaviour change; the debug build accepted both forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): read a neighbour's edge in nested functions, as S3 does (optimized build)

bd5a6a3 did not fix the optimized build: the release bench at that head (step
2f0db46a9eef5daf04d69e09) still refused ClosingPanes.swift:175 and :178, "sending 'edge' risks
causing data races" (a main-actor closure that captures both the task-isolated `edge` and the
pass's mutable `shown`/`rests`). The unoptimized release compile-check passed
(a3509913ec2ae257a046938c), so it does not catch this.

Now `facing(_:_:)` (where a neighbour's edge shows) and `restingEdge(_:_:)` (where it rests) are
nested functions that read `shown` and `rests`, and `place` calls them with `edge`, the shape
S3's merged code has (feat's optimized dogfood builds compile it). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…ners always follow and a closing pane always keeps its ground (FRAME OWNER S3) (#35161)

* test(pane motion): closing the last column while the camera moves back shows no shell (FRAME OWNER S3c, red)

The Claude re-review of #34170 at 7827a1f (P1, not a regression): a close of the
last column of a strip whose camera moves back shows the shell where the column was
(film P12: the close of a new column, about 700 -> 10 px over 22 frames). No pane
frame moves in reconcile, only the camera, which syncScroll sets after it: the
closing pane is hidden as one nothing closes over, and releaseRetired tears it down
before the camera starts.

New file PaneCloseCameraGroundTests: a full-width column and a 2/3 one, the camera
on the right one, Cmd-W on it. In every frame the window is covered by pane hosts
beyond the gaps at rest, and the closing pane's last frame shows no ground for the
landing to take away. Expected red at this commit; run on the fleet with --red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): a resize handle that stays shows in the middle of its gap in every frame of a close (FRAME OWNER S3d, red)

R-HARNESS's divider check on the refactor owner's films: during a close, a handle's rect was
up to 15 device px off the middle of the gap between its two panes, so a press on it could miss
by 7 pt. With S3 on, a closing pane shows its ground on its place; the gap a person sees beside
it must be the one whose handle stays.

PaneCloseKeptHandleTests closes a pane and checks, in the close's first frame, each spring frame
and at rest, that along the line through the kept handle's centre the run no pane host covers
(live, or closing with its ground) holds the centre, centred within a point:
- the divider on the closing pane's trailing side (p1 | p2 | p3 made by splitting p1 of
  p1 | p3, close p2): red now, the closing ground covers that gap;
- the divider below the closing pane (p1 | p2 over p3, close p2): red now;
- the edge of a column whose right split pane closes: red now;
- the edge before the strip's last column while the camera moves back: red now (the
  closing pane hides at once and the closed place shows the shell);
- guards, green now: the films' px-close-pane-3 (the divider on the leading side) and
  px-close-column-c3 (the edge before a closing middle column).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the strip's last-column close keeps two columns, so its camera springs back (FRAME OWNER S3d, red)

Red step ef256026fc9d27421a89b2a0 at 9c25336: the two camera cases closed the second of two
columns. A strip left with one column becomes a split screen (LayoutModel.removingColumn):
there is no camera to move back and no column edge after the close, and the remaining pane
slides in on its own spring (S3c already covers that close). So PaneCloseCameraGroundTests
stopped at its SETUP check (the camera rests at once) and the kept-handle case found no
handle.

Both now use three half-width columns with the camera at the end and close the third: two
columns stay, no pane frame moves, only the strip camera springs back (as
ColumnCloseScrollMotionTests' columns(3)). The kept handle is the edge of the second column.

The other results of that step stay as the red evidence: 3 cases red as expected (the closing
ground covers the kept divider's gap on the trailing side, below, and beside a column edge),
2 guards green (the films' px-close-pane-3 and px-close-column-c3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane keeps the gap where its handle stays, and the strip's last column closes on the camera (FRAME OWNER S3d)

Two follow-ups of S3c (#34170), red first (PaneCloseCameraGroundTests, PaneCloseKeptHandleTests).

1. The gap that shows is the kept handle's. A closing pane used to keep the gap only on its
   leading side (when its trailing side had neighbours too). Where the handle that stays is on
   its trailing or bottom side (a split pane's right sibling closing, a column's right split pane,
   the pane above a kept divider, the strip's last column), the closing ground covered the gap
   under that handle, and the shell showed beside the gap whose handle had gone: the handle was
   off its gap by up to the closing pane's width. Now each side looks up the handle in its gap in
   the layout the pane left (split dividers, column edges, row edges, in the pane's own space) and
   keeps the gap if the new layout keeps that handle; where the handle goes it covers the gap. The
   pane still rests at zero size where its neighbours meet (they meet one kept gap apart).

2. The strip's last column. Closing it with two or more columns left moves no pane: only the
   strip camera moves back. S3c hid such a pane at once (nothing closes over it), so its place
   showed the shell while the camera moved (film P12). A closing pane now waits for the camera
   the change sets (ScreenContentView.settleClosing, in syncScroll before the change's first
   frame): a side with no neighbour, opposite one with neighbours, collapses on the share of its
   space's camera travel (the strip's, or its row column's scroll) when no neighbour moves, so the
   pane rests at zero size when the camera does. A pane that nothing moves over still hides at
   once, as before; with nothing moving at all the retired hosts still go in the change's turn.

No new owner, timer or hold: the closing pane's rect stays a function of its neighbours' springs
and its space's camera, read each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the last column's close shifts the strip on its carried panes; the last row of a scrolling column closes on its scroll (FRAME OWNER S3d, red)

Red step ca6953a3b97f1ff22fa99cdf at e8878b7: the column case still stopped at its SETUP
check. A column's removal clamps the strip camera to the new end at once (ColumnScrollReducer:
"the view carries the panes horizontally from where they showed"), so no strip camera springs
back: the neighbour's frame is carried and springs from where it showed, and starts moving
only in syncScroll (settleCarried), after reconcile decided under S3 that nothing moves beside
the closing pane. The kept-handle case at that step was red for the right reason (the handle
292 pt off the middle of the closed place's shell, 610...1200).

- PaneCloseCameraGroundTests: the column case checks that the close moves the strip (not that
  the camera springs) and reuses ColumnCloseScrollMotionTests.columns(3). New case: one column
  of four 400-permille rows scrolled to the fourth, Cmd-W on it; three rows still overflow, so
  the row column's scroll springs back (a row camera keeps its spring) and no pane frame moves.
  Both check every frame along the line across the closed place and the ground left for the
  landing.
- PaneCloseKeptHandleTests: the camera case reuses columns(3) and is named for the strip's shift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): only a row column's scroll collapses a closing pane; the strip's shift is its carried panes' (FRAME OWNER S3d)

The strip camera never springs back for a close: a column's removal clamps it to the new end at
once and the view carries the panes from where they showed (ColumnScrollReducer), so the last
column's neighbour moves and its closing pane collapses on that neighbour's travel. The strip
camera share of 447bb3a had no case to serve: it is gone. What the strip's last column
needed is the other part of that commit: which closing panes nothing moves over is decided once
the change's cameras are set (settleClosing, after syncScroll's settleCarried), when the carried
neighbour already springs.

A row camera keeps its spring: closing the last row of a column whose rows still overflow moves
no pane, only the row column's scroll. That travel (ClosingPanes.Entry.rowTravel) collapses the
free side of a closing pane whose other y side rides a neighbour that does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): two last-row closes in a row leave no hole (FRAME OWNER S3d, red)

Codex review of 447bb3a (P1): a closing pane keeps the scroll travel it took at its close,
so a later change that takes the row scroll to another target leaves its collapse stale. The
same case shows a second hole: the first closing pane rides the second one, and closing panes
are placed in the order they closed, so the first reads where the second last was while the
second's free side moves away (the shell between them).

PaneCloseCameraGroundTests.twoLastRowClosesInARowLeaveNoHole: five 400-permille rows scrolled
to the fifth, the fifth closes, and six frames later the fourth (the row scroll takes another
target). Every frame along the column's middle shows no more shell than the rest gaps, and the
closing grounds leave under a point for the landing. The check closes a list of panes, and the
ground left for the landing is what only closing grounds cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane follows the row scroll's new target and rides a later close where it shows (FRAME OWNER S3d)

Red first: PaneCloseCameraGroundTests.twoLastRowClosesInARowLeaveNoHole.

- When its row column's scroll takes another target (a later change), a closing pane's travel
  starts again from where the scroll and its y sides show now (ClosingPanes.rects), so it still
  rests at zero size when the scroll lands; with no travel left it rests at once. The scroll's
  target is read every frame with its value (ScreenContentView.rowScrollTravel).
- Closing panes are placed from the last to close back to the first. A pane borders only panes
  live at its close, so no closing pane rides one that closed before it; one that closed earlier
  now reads where the later one shows in this frame instead of where it last was. The first of
  two closes in a row no longer leaves the shell between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): a second last-row close that makes the rows fit leaves no ground (FRAME OWNER S3d, red)

Codex review of 02de7be (P1): close the fourth of four 400-permille rows, then the third
before the first lands. The two rows left fit, so the row scroll goes and the second row
springs to its rest. The fourth closing pane rides the third, a closing pane, so it has no
rest to collapse toward (a rest is read from live neighbours only) and no scroll travel left:
its free side freezes and its ground stays for the landing to take away in one frame.

PaneCloseCameraGroundTests.aSecondLastRowCloseThatMakesTheRowsFitLeavesNoGround checks every
frame along the column's middle and the ground only closing panes cover before the landing.
Red step 03270980560f5bd06b1d21bc at 7fdf9f7 already showed the first two-closes case red
for its reason (up to 109 pt of shell between the two closing panes, frames 7 on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane that rides another closing pane rests where that one rests (FRAME OWNER S3d)

Red first: PaneCloseCameraGroundTests.aSecondLastRowCloseThatMakesTheRowsFitLeavesNoGround
(Codex review of 02de7be, P1).

- ClosingPanes.rects keeps, for each pane it places, where it rests (zero size where its sides
  meet: a side on its neighbours' rest, a free side on the rest of the side opposite). A side
  that rides a closing pane (one that closed after it, placed earlier in the pass) reads that
  rest, as a side on a live pane reads its target frame. The free side opposite it then
  collapses toward where that pane rests, and still reaches zero size when the row scroll that
  drove it is gone (the rows fit).
- A free side's travel now remembers its end (Side.end). When the end moves (a later change, a
  neighbour that closed too) or the row scroll takes another target, the travel starts again from
  where both sides and the scroll show now: the free side never jumps when its driver changes. A
  coordinate change (carry, shiftY) moves the end with the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the motion trace reports a closing pane's ground apart from the live panes (FRAME OWNER S3d, red)

R-HARNESS pair C judges each divider rect against the gap between the panes beside it, and its
judge reads closing panes as rows of the trace ("any pane row counts, a closing one too"). The
trace has none: the sampler skips every host that shows a closing ground (S3, so the live-pane
judges do not take a ground for a pane whose content is gone). So on an S3 build the judge
sees the closed place as the gap and reports the kept divider off its middle, as on a base
build.

PaneCloseKeptHandleTests.theTraceReportsAClosingPaneApartFromTheLivePanes: in a close's first
frame, the sample lists the closing pane under `closing` with the rect its ground shows at, and
not under `panes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): the motion trace lists closing grounds under `closing` (FRAME OWNER S3d)

Red first: PaneCloseKeptHandleTests.theTraceReportsAClosingPaneApartFromTheLivePanes.

ScreenContentView.motionSample (DEBUG and bench hooks only) now reports each shown closing pane's
ground as `[pane, x, y, width, height, alpha, z]` under a new `closing` key, apart from `panes`,
whose rows also say how live content is laid out (a closing ground lays out none). A trace judge
that centres a handle on the gap between its panes can now read the closing ground beside it
(R-HARNESS pair C); the live-pane judges read `panes` as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): two last-column closes in a row leave no ground past the strip (FRAME OWNER S3d, red for d369376)

The red for d369376 (a closing pane that rides another closing pane rests where that one
rests). af48fab's row case passed without that fix (step cadeb2ac05f197c36580c860): rows
that fit fill their column, so the frozen free edge of the old code was already at its rest.
That case stays as a guard.

PaneCloseCameraGroundTests.twoLastColumnClosesInARowLeaveNoGroundPastTheStrip: four 0.3-wide
columns (new columns keep their width), the camera at the end on the fourth; the fourth closes,
and six frames later the third. The strip fits after the first close and has an empty end
after the second. Without the fix the fourth closing pane rides the third with no rest to
collapse toward, so its free edge freezes in the strip's empty end, and its ground there goes
only with the landing. Every frame along the window's middle and the ground left for the
landing are checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): read a closing neighbour's edge through a closure literal (release build)

The release build of d369376 refused ClosingPanes.rects (bench step 8bba1189a5d3b22e2fcbde55,
ClosingPanes.swift:175 and :178): "sending 'edge' risks causing data races": `shown[$0].map(edge)`
and `rests[$0].map(edge)` hand the task-isolated parameter `edge` to `map` from a main-actor
closure. Both now call it in a closure literal (`map { edge($0) }`), as the live-frame reads
beside them already do. No behaviour change; the debug build accepted both forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): read a neighbour's edge in nested functions, as S3 does (optimized build)

bd5a6a3 did not fix the optimized build: the release bench at that head (step
2f0db46a9eef5daf04d69e09) still refused ClosingPanes.swift:175 and :178, "sending 'edge' risks
causing data races" (a main-actor closure that captures both the task-isolated `edge` and the
pass's mutable `shown`/`rests`). The unoptimized release compile-check passed
(a3509913ec2ae257a046938c), so it does not catch this.

Now `facing(_:_:)` (where a neighbour's edge shows) and `restingEdge(_:_:)` (where it rests) are
nested functions that read `shown` and `rests`, and `place` calls them with `edge`, the shape
S3's merged code has (feat's optimized dogfood builds compile it). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(pane motion): delete motion.frameOwner.contentPolicy; containers always follow and a closing pane always keeps its ground (FRAME OWNER S3)

FRAME OWNER REFACTOR stage S3, the switch deletion. S3 landed with `motion.frameOwner.contentPolicy`
default on (#34170, squash a08c406). The gate passed: the refactor owner's films, typing GO,
and the first FEAT DOGFOOD BUILD with S3 judged 10/10 (f1c3ebfd, feat cf86f55, STATUS 11111).
The rule for a stage: once its gate passes, the switch and the old path go. No behaviour change with
the default on.

Removed:
- `LayoutTunables.frameOwnerContentPolicy` (Debug Settings `motion.frameOwner.contentPolicy`) and its
  entry in `LayoutTunables.all`.
- `LayoutViewContext.contentPolicyOverride` and `followsContentPolicy`.
- `PaneMotionHold.containersFollow`: a content area's hold always lets its containers
  (`PaneMotionHoldFollowing`: a terminal's host, a browser's chrome) follow it; other subviews keep
  their frames, as with S1 (FrameOwnerS1HoldPathsTests).
- PaneHostView: `heldAreaFollows` is gone; a key hold whose content has a held area always follows
  (`heldArea != nil`: the surface stays at the pane edge that does not move, the rest size is
  announced and a press reversed in one hold announces its zero change).
- ScreenContentView.retireClosing: a springing close always keeps the closing host with its ground
  (still hidden on a canvas, as before).
- Doc comments that named the switch.

Tests:
- Deleted the OFF pins: PaneContentPolicyTests.aSwitchFlipMidSpringAppliesFromTheNextMotion, the
  `false` cases of CloseTeardownAfterLandingTests (2 tests) and of
  KeyboardResizeContentHoldTests.theProducerResizesOnceAndTheContainersFollowEveryFrame.
- Every `contentPolicyOverride = true` pin is gone (the rig, the S3 and S3d suites), so they prove the
  S3 path is the only path.
- PaneContentPolicyBench runs the one path; the off/on numbers stay in its doc (step
  4b4b80b33807e84dbb37cbfc).
No new test: nothing changes with the default on.

`schemas/defaults/cmux-next.json` is not edited by hand: the base autofix regenerates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…op-left corner, never scaled (cmux-ad m6 1b) (#35195)

* test(pane motion): closing the last column while the camera moves back shows no shell (FRAME OWNER S3c, red)

The Claude re-review of #34170 at 7827a1f (P1, not a regression): a close of the
last column of a strip whose camera moves back shows the shell where the column was
(film P12: the close of a new column, about 700 -> 10 px over 22 frames). No pane
frame moves in reconcile, only the camera, which syncScroll sets after it: the
closing pane is hidden as one nothing closes over, and releaseRetired tears it down
before the camera starts.

New file PaneCloseCameraGroundTests: a full-width column and a 2/3 one, the camera
on the right one, Cmd-W on it. In every frame the window is covered by pane hosts
beyond the gaps at rest, and the closing pane's last frame shows no ground for the
landing to take away. Expected red at this commit; run on the fleet with --red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): a resize handle that stays shows in the middle of its gap in every frame of a close (FRAME OWNER S3d, red)

R-HARNESS's divider check on the refactor owner's films: during a close, a handle's rect was
up to 15 device px off the middle of the gap between its two panes, so a press on it could miss
by 7 pt. With S3 on, a closing pane shows its ground on its place; the gap a person sees beside
it must be the one whose handle stays.

PaneCloseKeptHandleTests closes a pane and checks, in the close's first frame, each spring frame
and at rest, that along the line through the kept handle's centre the run no pane host covers
(live, or closing with its ground) holds the centre, centred within a point:
- the divider on the closing pane's trailing side (p1 | p2 | p3 made by splitting p1 of
  p1 | p3, close p2): red now, the closing ground covers that gap;
- the divider below the closing pane (p1 | p2 over p3, close p2): red now;
- the edge of a column whose right split pane closes: red now;
- the edge before the strip's last column while the camera moves back: red now (the
  closing pane hides at once and the closed place shows the shell);
- guards, green now: the films' px-close-pane-3 (the divider on the leading side) and
  px-close-column-c3 (the edge before a closing middle column).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the strip's last-column close keeps two columns, so its camera springs back (FRAME OWNER S3d, red)

Red step ef256026fc9d27421a89b2a0 at 9c25336: the two camera cases closed the second of two
columns. A strip left with one column becomes a split screen (LayoutModel.removingColumn):
there is no camera to move back and no column edge after the close, and the remaining pane
slides in on its own spring (S3c already covers that close). So PaneCloseCameraGroundTests
stopped at its SETUP check (the camera rests at once) and the kept-handle case found no
handle.

Both now use three half-width columns with the camera at the end and close the third: two
columns stay, no pane frame moves, only the strip camera springs back (as
ColumnCloseScrollMotionTests' columns(3)). The kept handle is the edge of the second column.

The other results of that step stay as the red evidence: 3 cases red as expected (the closing
ground covers the kept divider's gap on the trailing side, below, and beside a column edge),
2 guards green (the films' px-close-pane-3 and px-close-column-c3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane keeps the gap where its handle stays, and the strip's last column closes on the camera (FRAME OWNER S3d)

Two follow-ups of S3c (#34170), red first (PaneCloseCameraGroundTests, PaneCloseKeptHandleTests).

1. The gap that shows is the kept handle's. A closing pane used to keep the gap only on its
   leading side (when its trailing side had neighbours too). Where the handle that stays is on
   its trailing or bottom side (a split pane's right sibling closing, a column's right split pane,
   the pane above a kept divider, the strip's last column), the closing ground covered the gap
   under that handle, and the shell showed beside the gap whose handle had gone: the handle was
   off its gap by up to the closing pane's width. Now each side looks up the handle in its gap in
   the layout the pane left (split dividers, column edges, row edges, in the pane's own space) and
   keeps the gap if the new layout keeps that handle; where the handle goes it covers the gap. The
   pane still rests at zero size where its neighbours meet (they meet one kept gap apart).

2. The strip's last column. Closing it with two or more columns left moves no pane: only the
   strip camera moves back. S3c hid such a pane at once (nothing closes over it), so its place
   showed the shell while the camera moved (film P12). A closing pane now waits for the camera
   the change sets (ScreenContentView.settleClosing, in syncScroll before the change's first
   frame): a side with no neighbour, opposite one with neighbours, collapses on the share of its
   space's camera travel (the strip's, or its row column's scroll) when no neighbour moves, so the
   pane rests at zero size when the camera does. A pane that nothing moves over still hides at
   once, as before; with nothing moving at all the retired hosts still go in the change's turn.

No new owner, timer or hold: the closing pane's rect stays a function of its neighbours' springs
and its space's camera, read each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the last column's close shifts the strip on its carried panes; the last row of a scrolling column closes on its scroll (FRAME OWNER S3d, red)

Red step ca6953a3b97f1ff22fa99cdf at e8878b7: the column case still stopped at its SETUP
check. A column's removal clamps the strip camera to the new end at once (ColumnScrollReducer:
"the view carries the panes horizontally from where they showed"), so no strip camera springs
back: the neighbour's frame is carried and springs from where it showed, and starts moving
only in syncScroll (settleCarried), after reconcile decided under S3 that nothing moves beside
the closing pane. The kept-handle case at that step was red for the right reason (the handle
292 pt off the middle of the closed place's shell, 610...1200).

- PaneCloseCameraGroundTests: the column case checks that the close moves the strip (not that
  the camera springs) and reuses ColumnCloseScrollMotionTests.columns(3). New case: one column
  of four 400-permille rows scrolled to the fourth, Cmd-W on it; three rows still overflow, so
  the row column's scroll springs back (a row camera keeps its spring) and no pane frame moves.
  Both check every frame along the line across the closed place and the ground left for the
  landing.
- PaneCloseKeptHandleTests: the camera case reuses columns(3) and is named for the strip's shift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): only a row column's scroll collapses a closing pane; the strip's shift is its carried panes' (FRAME OWNER S3d)

The strip camera never springs back for a close: a column's removal clamps it to the new end at
once and the view carries the panes from where they showed (ColumnScrollReducer), so the last
column's neighbour moves and its closing pane collapses on that neighbour's travel. The strip
camera share of 447bb3a had no case to serve: it is gone. What the strip's last column
needed is the other part of that commit: which closing panes nothing moves over is decided once
the change's cameras are set (settleClosing, after syncScroll's settleCarried), when the carried
neighbour already springs.

A row camera keeps its spring: closing the last row of a column whose rows still overflow moves
no pane, only the row column's scroll. That travel (ClosingPanes.Entry.rowTravel) collapses the
free side of a closing pane whose other y side rides a neighbour that does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): two last-row closes in a row leave no hole (FRAME OWNER S3d, red)

Codex review of 447bb3a (P1): a closing pane keeps the scroll travel it took at its close,
so a later change that takes the row scroll to another target leaves its collapse stale. The
same case shows a second hole: the first closing pane rides the second one, and closing panes
are placed in the order they closed, so the first reads where the second last was while the
second's free side moves away (the shell between them).

PaneCloseCameraGroundTests.twoLastRowClosesInARowLeaveNoHole: five 400-permille rows scrolled
to the fifth, the fifth closes, and six frames later the fourth (the row scroll takes another
target). Every frame along the column's middle shows no more shell than the rest gaps, and the
closing grounds leave under a point for the landing. The check closes a list of panes, and the
ground left for the landing is what only closing grounds cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane follows the row scroll's new target and rides a later close where it shows (FRAME OWNER S3d)

Red first: PaneCloseCameraGroundTests.twoLastRowClosesInARowLeaveNoHole.

- When its row column's scroll takes another target (a later change), a closing pane's travel
  starts again from where the scroll and its y sides show now (ClosingPanes.rects), so it still
  rests at zero size when the scroll lands; with no travel left it rests at once. The scroll's
  target is read every frame with its value (ScreenContentView.rowScrollTravel).
- Closing panes are placed from the last to close back to the first. A pane borders only panes
  live at its close, so no closing pane rides one that closed before it; one that closed earlier
  now reads where the later one shows in this frame instead of where it last was. The first of
  two closes in a row no longer leaves the shell between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): a second last-row close that makes the rows fit leaves no ground (FRAME OWNER S3d, red)

Codex review of 02de7be (P1): close the fourth of four 400-permille rows, then the third
before the first lands. The two rows left fit, so the row scroll goes and the second row
springs to its rest. The fourth closing pane rides the third, a closing pane, so it has no
rest to collapse toward (a rest is read from live neighbours only) and no scroll travel left:
its free side freezes and its ground stays for the landing to take away in one frame.

PaneCloseCameraGroundTests.aSecondLastRowCloseThatMakesTheRowsFitLeavesNoGround checks every
frame along the column's middle and the ground only closing panes cover before the landing.
Red step 03270980560f5bd06b1d21bc at 7fdf9f7 already showed the first two-closes case red
for its reason (up to 109 pt of shell between the two closing panes, frames 7 on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): a closing pane that rides another closing pane rests where that one rests (FRAME OWNER S3d)

Red first: PaneCloseCameraGroundTests.aSecondLastRowCloseThatMakesTheRowsFitLeavesNoGround
(Codex review of 02de7be, P1).

- ClosingPanes.rects keeps, for each pane it places, where it rests (zero size where its sides
  meet: a side on its neighbours' rest, a free side on the rest of the side opposite). A side
  that rides a closing pane (one that closed after it, placed earlier in the pass) reads that
  rest, as a side on a live pane reads its target frame. The free side opposite it then
  collapses toward where that pane rests, and still reaches zero size when the row scroll that
  drove it is gone (the rows fit).
- A free side's travel now remembers its end (Side.end). When the end moves (a later change, a
  neighbour that closed too) or the row scroll takes another target, the travel starts again from
  where both sides and the scroll show now: the free side never jumps when its driver changes. A
  coordinate change (carry, shiftY) moves the end with the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): the motion trace reports a closing pane's ground apart from the live panes (FRAME OWNER S3d, red)

R-HARNESS pair C judges each divider rect against the gap between the panes beside it, and its
judge reads closing panes as rows of the trace ("any pane row counts, a closing one too"). The
trace has none: the sampler skips every host that shows a closing ground (S3, so the live-pane
judges do not take a ground for a pane whose content is gone). So on an S3 build the judge
sees the closed place as the gap and reports the kept divider off its middle, as on a base
build.

PaneCloseKeptHandleTests.theTraceReportsAClosingPaneApartFromTheLivePanes: in a close's first
frame, the sample lists the closing pane under `closing` with the rect its ground shows at, and
not under `panes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): the motion trace lists closing grounds under `closing` (FRAME OWNER S3d)

Red first: PaneCloseKeptHandleTests.theTraceReportsAClosingPaneApartFromTheLivePanes.

ScreenContentView.motionSample (DEBUG and bench hooks only) now reports each shown closing pane's
ground as `[pane, x, y, width, height, alpha, z]` under a new `closing` key, apart from `panes`,
whose rows also say how live content is laid out (a closing ground lays out none). A trace judge
that centres a handle on the gap between its panes can now read the closing ground beside it
(R-HARNESS pair C); the live-pane judges read `panes` as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pane motion): two last-column closes in a row leave no ground past the strip (FRAME OWNER S3d, red for d369376)

The red for d369376 (a closing pane that rides another closing pane rests where that one
rests). af48fab's row case passed without that fix (step cadeb2ac05f197c36580c860): rows
that fit fill their column, so the frozen free edge of the old code was already at its rest.
That case stays as a guard.

PaneCloseCameraGroundTests.twoLastColumnClosesInARowLeaveNoGroundPastTheStrip: four 0.3-wide
columns (new columns keep their width), the camera at the end on the fourth; the fourth closes,
and six frames later the third. The strip fits after the first close and has an empty end
after the second. Without the fix the fourth closing pane rides the third with no rest to
collapse toward, so its free edge freezes in the strip's empty end, and its ground there goes
only with the landing. Every frame along the window's middle and the ground left for the
landing are checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): read a closing neighbour's edge through a closure literal (release build)

The release build of d369376 refused ClosingPanes.rects (bench step 8bba1189a5d3b22e2fcbde55,
ClosingPanes.swift:175 and :178): "sending 'edge' risks causing data races": `shown[$0].map(edge)`
and `rests[$0].map(edge)` hand the task-isolated parameter `edge` to `map` from a main-actor
closure. Both now call it in a closure literal (`map { edge($0) }`), as the live-frame reads
beside them already do. No behaviour change; the debug build accepted both forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pane motion): read a neighbour's edge in nested functions, as S3 does (optimized build)

bd5a6a3 did not fix the optimized build: the release bench at that head (step
2f0db46a9eef5daf04d69e09) still refused ClosingPanes.swift:175 and :178, "sending 'edge' risks
causing data races" (a main-actor closure that captures both the task-isolated `edge` and the
pass's mutable `shown`/`rests`). The unoptimized release compile-check passed
(a3509913ec2ae257a046938c), so it does not catch this.

Now `facing(_:_:)` (where a neighbour's edge shows) and `restingEdge(_:_:)` (where it rests) are
nested functions that read `shown` and `rests`, and `place` calls them with `edge`, the shape
S3's merged code has (feat's optimized dogfood builds compile it). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(pane motion): delete motion.frameOwner.contentPolicy; containers always follow and a closing pane always keeps its ground (FRAME OWNER S3)

FRAME OWNER REFACTOR stage S3, the switch deletion. S3 landed with `motion.frameOwner.contentPolicy`
default on (#34170, squash a08c406). The gate passed: the refactor owner's films, typing GO,
and the first FEAT DOGFOOD BUILD with S3 judged 10/10 (f1c3ebfd, feat cf86f55, STATUS 11111).
The rule for a stage: once its gate passes, the switch and the old path go. No behaviour change with
the default on.

Removed:
- `LayoutTunables.frameOwnerContentPolicy` (Debug Settings `motion.frameOwner.contentPolicy`) and its
  entry in `LayoutTunables.all`.
- `LayoutViewContext.contentPolicyOverride` and `followsContentPolicy`.
- `PaneMotionHold.containersFollow`: a content area's hold always lets its containers
  (`PaneMotionHoldFollowing`: a terminal's host, a browser's chrome) follow it; other subviews keep
  their frames, as with S1 (FrameOwnerS1HoldPathsTests).
- PaneHostView: `heldAreaFollows` is gone; a key hold whose content has a held area always follows
  (`heldArea != nil`: the surface stays at the pane edge that does not move, the rest size is
  announced and a press reversed in one hold announces its zero change).
- ScreenContentView.retireClosing: a springing close always keeps the closing host with its ground
  (still hidden on a canvas, as before).
- Doc comments that named the switch.

Tests:
- Deleted the OFF pins: PaneContentPolicyTests.aSwitchFlipMidSpringAppliesFromTheNextMotion, the
  `false` cases of CloseTeardownAfterLandingTests (2 tests) and of
  KeyboardResizeContentHoldTests.theProducerResizesOnceAndTheContainersFollowEveryFrame.
- Every `contentPolicyOverride = true` pin is gone (the rig, the S3 and S3d suites), so they prove the
  S3 path is the only path.
- PaneContentPolicyBench runs the one path; the off/on numbers stay in its doc (step
  4b4b80b33807e84dbb37cbfc).
No new test: nothing changes with the default on.

`schemas/defaults/cmux-next.json` is not edited by hand: the base autofix regenerates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(browser): a key resize's held page stays on its page area's top-left in every frame (cmux-ad m6 1b, red)

The interim browser contract for key resizes (FRAME OWNER REFACTOR, STATUS 11198; Lawrence:
"tabbar moves smoothly on ctrl shift hjkl but browsers/terminals do not anymore"). S3 holds a
Chromium page through a key resize on the pane edges that do not move, so a pane whose leading
edge moves slides over a page that stands still: on the refactor owner's films (build 437f5a22,
STATUS 11448) the page's left edge is on its pane's in 0 of 190 motion frames of held keys, up to
1504 device px inside, and 0 of 50 for single presses.

CEFDeferredTabShowTests.aKeyResizeHoldKeepsThePageOnThePageAreasTopLeft: a live page in its pane's
container holds for a key resize whose trailing edge stays (S3's hold edges); the container's
leading edge moves right 120 pt (the pane shrinks), then left 240 pt (it grows), then back. In
every frame the page's top-left corner must be on the page area's top-left (0.5 pt), its size the
held one; the pane clips it, its backdrop is the page's own, and it takes its area's size when the
hold ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(browser): type the key-resize hold test's frame steps (cmux-ad m6 1b, red)

Red step f4e4827a1e6a5a3eca7c65c1 at 619eb26 did not compile the test: "the compiler is
unable to type-check this expression in reasonable time" on the one-line sum of three mapped
ranges, and an inference error after it. The steps are now three typed arrays (right 120 pt, left
to -120 pt, back to the start). The test is otherwise the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(browser): in a key resize a Chromium page moves with its pane's top-left corner, never scaled (cmux-ad m6 1b)

Red first: CEFDeferredTabShowTests.aKeyResizeHoldKeepsThePageOnThePageAreasTopLeft.

The interim browser contract for key resizes (FRAME OWNER REFACTOR, STATUS 11198), until S5's
layer hosting:
1. The page shows the newest frame Chromium presented at its own size: never scaled.
2. Its top-left corner is on the pane's page area's top-left in each frame, so it moves with the
   pane's leading and top edges, as the tab strip does (S1, S3).
3. The pane clips it, and the page's own backdrop fills the rest of the area.
4. Chromium hears one new size per press, after the landing (unchanged).

Only item 2 changes. CEFTabContentView.layoutContent places a key hold's page at the page frame's
top-left corner, the corner a divider drag's hold already uses. It no longer rides S3's hold
edges (the pane edges that do not move). The page window follows its host view in each motion
frame through the existing path (CEFHostView.pagesNeedUpdate: one geometry post per moved page,
budget 0.2 ms), the one a hold-mode divider drag uses.

Behind `browserResize.keyResizeTopLeft` (Debug Settings > Browser Resize), default on, latched
when each key hold begins. A kill switch only: it goes once the refactor owner's off/on film
passes.

Item 1's snapshot part: CEFTabContentView.snapshotView (NSImageView, .scaleAxesIndependently) was
dead code. It was never added to a view and never given an image, so it never showed. It is
deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…esize (switch-on path; default stays off) (#35470)

* test(next): S4 ON a drag shows each terminal size no later with motion.frameOwner.terminals on than off (red)

Films ab-s4d-sw-22 (build 645ad29d, px-row-drag-3r, judge v7): in a divider
drag, the terminal's content edge lagged its pane edge mostly 2 refreshes
with the switch on (p2's moving edge -4 px, 156 of 667 frames), against 1
refresh with it off (-2 px, 117 of 676).

TerminalFrameOwnerDragCadenceTests drags a real Ghostty surface one size per
refresh of its window's display link (a FrameClient on the window's
FrameScheduler), first with the switch off, then on. At each refresh it reads
how many refreshes old the size of the shown frame is, and with the switch on
counts Ghostty's state for the newest size (ready: drawn, not presented yet;
pending: not drawn yet). On may not be later than off (median and p95).
Display workers only.

Red command: cmux-ci run --display --label gui-session-v1 --class light
--script scripts/ci/package-test-lane.sh --arg=suite
--arg=Packages/macOS/CmuxNext --arg=TerminalFrameOwnerDragCadenceTests

* test(next): S4 ON a kept frame stays on the edges its S3 hold names (trailing, bottom) while its new size waits (red)

S4 ON PR defect (b), found by the Codex review at 15c8848. In a key
resize the host follows its pane (FRAME OWNER S3, #34170) and gives the
surface its size on the hold's still edges (PaneMotionHold.producerFrame).
With motion.frameOwner.terminals on, while a new size waits for its frame,
place() kept the old frame on the wanted frame's leading edge, and on its
top edge unless the surface hold said bottom. So with a trailing hold the
kept frame's trailing edge moved off the still edge (by the size change), and
with a bottom hold its bottom edge did, until the new size showed.

aKeptFrameStaysOnTheEdgesItsHoldNames: a trailing hold with a narrower final
size, then a bottom hold with a shorter one. While each size waits, the kept
frame keeps the rest size on the still edges; the landed frame is the
producer frame.

Red command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=TerminalSurfaceCommitSizeTests

* fix(next): S4 ON a kept frame rides the still edges of the S3 hold its host follows (trailing, bottom)

Fixes aKeptFrameStaysOnTheEdgesItsHoldNames (previous commit). With
motion.frameOwner.terminals on, while a new size waits for its frame, the
kept frame now sits where FRAME OWNER S3's PaneMotionHold.producerFrame puts
the surface: on the trailing edge when the followed hold keeps its trailing
edge still, and on the bottom edge when the hold or the surface hold says
bottom. Before, it rode the wanted frame's leading edge, and its top edge
unless the surface hold said bottom.

- TerminalSurfaceView.place(_:anchor:trailing:) and anchoredFrame(...,
  trailing:) carry the horizontal edge (TerminalFrameOwnership.trailing).
- TerminalHostView: surfaceAnchor is bottom when the surface hold or the
  followed hold says bottom; surfaceTrailing is the followed hold's trailing.

With the switch off nothing changes: place() sets the frame it gets, as
before; only the kept frame (switch on) uses the edges.

* test(next): S4 ON the row-drag lag test drives the real layout path (the bare-window version did not reproduce)

f355d15 resized a bare terminal from a display-link tick: step
569b148813d9d863e5bb673b passed with the switch off and on (the shown frame
0 refreshes old in all 52 refreshes), so it did not reproduce the films' row
drag lag (ab-s4d-sw-22: content edge 2 refreshes behind with the switch on, 1
off). In a drag the surface keeps its start size; only its origin moves.

aRowDragMovesTheTerminalWithItsPaneEdgeNoLaterWithTheSwitchOnThanOff now
uses the real path: p1 above p2 in a LayoutRootView in a window on screen, a
terminal in each, and the app's divider drag (ScreenContentView.handleDrag),
one 2 pt move per refresh of the window's display link, switch off then on.
After each Core Animation commit it reads, in the committed layer tree, p2's
pane host top edge and its surface layer's top edge. On may not lag in more
refreshes, or by more, than off. Display workers only.

* debug(next): S4 the debug.motion trace records each terminal's present path per frame (for the ON row-drag film)

FRAME OWNER S4 defect (a): with motion.frameOwner.terminals on, the films'
row drag shows the terminal's content edge 2 refreshes behind its pane edge
(1 with it off). The committed layer geometry has no lag in either mode (the
real-layout test f863559), and the film's trace shows the panes moving
the same way, so the extra refresh is in the present path.

Debug and bench builds only (DEBUG || CMUX_BENCH_HOOKS): a debug.motion
trace sampler `terminal`, per display frame and shown surface: its layer
rect (window points, top-left), the shown IOSurface size, Ghostty's render
counters (frames, off-main presents, fresh presents, display passes, main
draws, late presents), whether its window's commit owns its frame, the
presenter's state for it (TerminalFramePresenter.debugState: request, watch,
commit or -) and ghostty_surface_host_frame without presenting. It reads
only while the trace records. Registered once by the first surface view.

* test(next): cmux-ad m6 1a: in a key resize each terminal shows its pane's size in every frame, and its program resizes once per press (red)

Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl shift hjkl but
browsers/terminals do not anymore". The key resize's hold (RESIZE KEYS K3,
TerminalSurfaceView.restAt) holds each surface at the press's final size for
the whole spring; with FRAME OWNER S1/S3 the tab strip and the host follow
the pane every frame, so the terminal's pixels freeze, then jump. With
motion.frameOwner.terminals on (S4), the size still changes once per press.

Contract (lead, cmux-ad m6): pixels per frame, program size per press.
TerminalKeyResizeFrameTests, on the real path (LayoutRootView p1 | p2, a
spring with Reduce Motion off and S3's content policy on, PaneResize.press),
switch on: in every committed frame of the spring each terminal's surface
layer is within 1 device px of its pane area's size in that frame or the
frame before; each PTY gets at most one grid for the press; ready-or-keep
holds. It prints the frame intervals (p50, p95, max). Display workers only.

Red command: cmux-ci run --display --label gui-session-v1 --class light
--script scripts/ci/package-test-lane.sh --arg=suite
--arg=Packages/macOS/CmuxNext --arg=TerminalKeyResizeFrameTests

* test(next): cmux-ad m6 1a: the key-resize frame test samples 1.5 s of wall-clock time (idle run-loop turns ended it before the spring moved)

Step 2780d048a25b653555a50640 at 418d4a8 failed its precondition, not the contract: "moving 1 >= 4". It stopped after 12 commits with no change, and a run-loop turn is not a refresh: 12 idle turns passed before the spring's first frame. The test now keeps every committed state that differs from the last one for 1.5 s of wall-clock time; settle waits for 300 ms with no change. Still red-first: no fix in this commit.

* test(next): cmux-ad m6 1a: the key-resize frame test steps the spring itself (the display-link spring gave 2 states)

Step dd347b7008f47da4762b1d62 at 73c411a failed the precondition again: "moving 2 >= 4". In 1.5 s the committed state changed twice, so the display-link spring did not animate on that host. The test now drives the layout as TabStripFollowsPaneTickTests does: driver.isManual, then per frame advanceFrame(1/120), its layout pass and its Core Animation commit (both presses). It also times each frame's main work (step + layout). Still red-first: no fix in this commit.

* test(next): cmux-ad m6 1a: the key-resize frame test checks each terminal's top-left on its pane's (the film gate), and sizes the pane has had

pixel-perfect's film on dogfood 437f5a22 (px-keys-single-3k-s1r): the terminal right of the divider has its left ring on its pane's left edge in 14 of 50 motion frames, off by -8 to +21 px in 20, cut off in 16. A size check alone misses a wrongly placed frame. Per committed frame and terminal the test now requires: the surface's top-left on its area's top-left (1 device px, in the window's root layer); a size the pane had in this press, this frame or earlier; the final size after the landing; at most one PTY grid per press. Still red-first: no fix in this commit.

* test(next): cmux-ad m6 1a: the S3-hold kept-frame case expects the top-left anchor (scenario kept from S4 defect (b))

The case that once showed a kept frame on the wrong edge (red step d509169a15477622073341e5) stays, with the lead's m6 rule as its expectation: in a key resize whose S3 hold keeps the pane's trailing or bottom edge still, when the pane narrows or gets shorter the terminal asks its new size, keeps its last frame anchored on its area's top-left (never another edge), and lands at the area. The size now comes from the pane's area (ResizingContainer.terminalHeight added), as in the app.

Red on the current code: the followed surface keeps its start size at the still edge (FRAME OWNER S3 + K3), so no new size waits: 'the narrower size did not wait for its frame'. The fix is the next commit.

* test(next): cmux-ad m6 1a: the key-resize frame test uses the app's PaneContentView, so the key hold follows the pane as in the app (red)

Step f3f4a7f9378f4d1455c4e12b (6cf19a0, cmux11) ran the stepped spring
(27 frames, main per frame p50 0.08 ms, max 0.22 ms; one PTY grid per press)
and failed 48 frames: "layer 500x600, pane area 450x600". But its panes were
plain NSViews, so the key hold held the whole content (RESIZE KEYS K3 without
FRAME OWNER S1/S3): that is a red for the K3 hold, not the app's path.

The panes are now the app's PaneContentView (its tab strip over its content
area): the key hold follows the pane in every frame (S1/S3), as in the app,
and only the producer holds. Per committed frame and terminal: top-left
within 1 device px of its area's, a size its pane has had in this press,
never stretched (layer px == the shown IOSurface's px); after the landing the
final size; at most one PTY grid per press. Display workers only. Still
red-first: no fix in this commit.

* fix(next): cmux-ad m6 1a: with motion.frameOwner.terminals on, a key resize shows each terminal at its pane's size in every frame; the PTY gets one grid per press

Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl shift hjkl but
browsers/terminals do not anymore". In a key resize the host follows its
pane (FRAME OWNER S3), but the surface kept one size per press: restAt held
it at the final size (or S3 at the start size) on the hold's still edges, so
the content froze, then jumped (film on 437f5a22: the terminal right of the
divider had its left ring on its pane edge in 14 of 50 motion frames).

Contract (lead, cmux-ad m6): pixels per frame, program size per press. With
the switch on:
- TerminalHostView.followedSurfaceFrame gives the surface the area it shows
  in, in every frame. The frame owner (S4) shows each size's frame with its
  bounds, or keeps the last frame anchored top-left until it is drawn (one
  size in flight, the newest next): never stretched, never off the pane's
  top-left. No main-thread draw or wait: set_size goes once per landing, and
  the drawing is on Ghostty's renderer thread.
- TerminalSurfaceView.restAt still reports the final grid at the press (the
  PTY's one resize), but no longer holds the surface at the final size.
- The kept frame's edge is the top-left again: S4 defect (b)'s code (the
  kept frame on the S3 hold's trailing or bottom edge, b0a6f60) is
  reverted. Its test keeps its scenario with the top-left expectation
  (previous commit).
Switch off: unchanged (S3's producer hold on the still edges).

Green command: cmux-ci run --display --label gui-session-v1 --class light
--script scripts/ci/package-test-lane.sh --arg=suite
--arg=Packages/macOS/CmuxNext --arg=TerminalKeyResizeFrameTests and the
terminal suites (TerminalSurfaceCommitSizeTests, KeyboardResizeHoldTakeoverTests,
KeyboardResizeContentHoldTests, DividerDragTerminalSteadyTests, ...).

* test: remove TerminalFrameOwnerDragCadenceTests (it never reproduced the drag's +1 refresh)

The drag cadence suite (f863559) drove a real-layout row drag to find the
+1 refresh that films showed in drags. It never reproduced it, and
pixel-perfect's alternating rounds (STATUS 11574) showed the +1 refresh comes
and goes between launches in both modes: it is not caused by
motion.frameOwner.terminals. The suite holds no regression, so it goes before
the ON PR.

* feat(next): motion.frameOwner.terminals on by default (FRAME OWNER S4 ON step)

Terminals are ready-or-keep by default: in every Core Animation commit a
terminal's bounds and its pixels change together, or both keep their last
size, and in a key resize each terminal shows its pane's size in every frame
while its PTY gets one grid per press (cmux-ad m6). Release and RC read the
default, so this is the step that ships it. The switch stays for the A/B and
goes in the deletion PR.

What's New: release-notes/next/highlights/resize-keys-terminals-follow.md.

* test(next): S4 kept-frame test builds after the feat merge (PaneMotionHold lost containersFollow in #35161)

#35161 deleted motion.frameOwner.contentPolicy and PaneMotionHold's
containersFollow argument (a content area's hold always lets its containers
follow now). The merge e37c317 had no text conflict, but
aKeptFrameStaysOnTheEdgesItsHoldNames still passed the argument, so
CmuxNextAppTests did not build (fleet step 97850bf9a5ad4c353d88329d at
8c981e3: "extra argument 'containersFollow' in call", lines 99 and 118).
The hold means the same without it.

* revert(next): motion.frameOwner.terminals back to off until the key-resize tests check the m6 contract

This takes back b3ab63b (default on and its What's New highlight) on this
branch only, for one commit. With the default on, 7 tests fail at
c642350 (fleet step e9e6a30c3d9effb058d77e64): they check FRAME OWNER
S3's held surface, one size per press, which the lead's m6 contract replaces
(pixels per frame, program size per press). The next commit changes them to
that contract and is red with the switch off. The commit after it turns the
switch on again.

* test(next): cmux-ad m6 (c): the key-resize terminal tests check pixels per frame and one grid per press (red with the switch off)

The lead's m6 contract replaces FRAME OWNER S3's held surface: in every
frame of a key resize each terminal shows its pane's size (or keeps its last
frame, top-left, until the new size is drawn: TerminalKeyResizeFrameTests),
and its PTY gets at most one grid per press. These tests checked the
opposite, one surface size per press:
- PaneContentPolicyTests: aKeyPressShowsEachSurfaceAtItsPanesSizeAndSendsOneGrid
  (was ...SizesEachSurfaceOnce...), aHeldKeyShowsEachSurfaceAtItsPanesSizeAnd
  SendsAtMostOneGridPerPress (was ...SizesEachSurfaceOnlyInAPressTurn), and
  aPressAndItsReverseInOneHoldSendEachGridInItsTurn (the surface follows in
  every frame and rests at its start size).
- KeyboardResizeContentHoldTests.theTerminalFollowsItsPaneInEveryFrameAndSends
  OneGridPerTerminal (was theProducerResizesOnce...).
- KeyboardResizeReflowAfterLandingTests.theTerminalFollowsItsPaneInEveryFrame
  AndItsGridGoesAtThePress (was theProducerLaysOutOnce...).
These windows draw nothing, so each size shows at once. The grid checks do
not change.

TerminalLaunchFrameKeeperTests: the two resize cases wait (2 s at most) for
the surface to show its new size. With the frame owner it takes that size in
the commit that shows its frame, a refresh or two after the layout, and the
keeper saves it then. These cases pass with the switch off and on.

Red at this commit, with motion.frameOwner.terminals off (the previous
commit). The next commit turns it on.

* feat(next): motion.frameOwner.terminals on by default (FRAME OWNER S4 ON step)

Terminals are ready-or-keep by default: in every Core Animation commit, a
terminal's bounds and its pixels change together, or both keep their last
size. In a key resize, each terminal shows its pane's size in every frame,
and its PTY gets one grid per press (cmux-ad m6). Release and RC read the
default, so this step ships S4. The switch stays for the A/B, and the
deletion PR removes it.

This is b3ab63b again (taken back for one commit by f231b3c). The
contract tests of 2da280e go green here.

What's New: release-notes/next/highlights/resize-keys-terminals-follow.md.

* release-notes: the key-resize terminal highlight is a fix (dogfood 363e39f2: terminals stopped following their pane)

* test(next): S4 ON review of #35470: one grid when a frame waits at the landing, a terminal must take new sizes, a low prompt rides the bottom (red)

The independent review of #35470 at 665b187 (BLOCK, comment
6635411077):
- F1: aTerminalWhoseFrameWaitsAtTheLandingGetsOneGridForThePress (display
  workers). p1 holds its frames with mode 2026, with a locked grid as the
  daemon locks it. At the landing a size is still in flight, and the
  landing's release reported that stale size's grid, then the final grid
  again: one press sent 3 grids. The test requires that a size waited and
  that one was in flight at the landing, so it cannot pass vacuously.
- T1: aKeyResizeShowsEachTerminal... also requires each terminal to take a
  new size in more than 3 spring frames. The size check allowed the start
  size, so a terminal that never moved passed.
- F2: aPromptOnTheLastRowRidesTheBottomOfAPaneAKeyGrowsDown. A pane that
  Ctrl-Shift-J grows down keeps a prompt on its last row in view (S3's rule,
  which a split and a close still keep): the surface keeps the final size on
  the moving bottom edge. With the switch on, the surface took the pane's
  size in each frame and showed the final grid's top rows, so the prompt was
  out of view for the whole spring.

Expected red at this commit: F1 (3 grids for p1) and F2 (the surface never
taller than its pane). T1 is green here: it guards the m6 fix.

* fix(next): S4 ON: a size in flight that is no longer wanted reports no grid; a low prompt rides the bottom of a pane a key grows down

Fixes for the independent review of #35470 at 665b187 (BLOCK, comment
6635411077):
- F1: updateSurfaceSize holds the report while the size in flight is not the
  wanted size (frameOwnership.inFlight != wanted). The landing's release
  sized the surface from a stale spring size whose frame was still waiting,
  and it reported that grid, because the spring was over. Then the newest
  size reported the final grid again: final, stale, final. Now the wanted
  size reports its grid when it goes to Ghostty (requestWantedFrame), and it
  matches the final grid the press sent, so the press sends one grid. A
  forced report waits the same way and goes with the wanted size.
- F2: restAt keeps S3's bottom hold in a key resize when motionAnchor answers
  .bottom (a pane that grows down, with the prompt in its lower half).
  followedSurfaceFrame keeps that hold with the switch on. Before, the
  surface took the pane's size in each frame and showed the final grid's top
  rows, so the prompt was out of view for the whole spring. The PTY still
  gets one grid, and every other key resize follows its pane in every frame.

Red tests: 712df02.

* test(next): S4 ON review of #35470 (F3): a parked terminal shown during a key hold follows its pane from its first frame (red)

A terminal tab parked when a key press began, then shown again mid-spring,
is unhidden before its area hands it the hold (that happens in the next
layout pass). So restAt saw no followed hold and held the surface at the
press's final size, a size its pane did not have yet. Expected red at this
commit: the switch's frame shows the surface at the final size.

* fix(next): S4 ON: a terminal shown in a holding area mid-spring follows its pane (review of #35470, F3)

restAt treats a host whose content area holds (PaneContentAreaView.isHolding)
as a follower, also before the area's next layout pass hands it the hold. A
parked tab shown again mid-spring took the press's final size at once.
Red test: bb46d44.

* test(next): S4 ON F1 test: p1 keeps holding its frames through the spring; check the wait per frame, not the presenter's keeps

Fleet steps bd84255cb4eb81bbd1fbcd67 (red ref) and 4aa9f6c6975072ab54ffdcb0
(fix ref) both stopped at the precondition 'presenter.stats.keeps grew'. The
presenter counts a keep only after a group's 2 refreshes, and the test steps
the spring faster than the display refreshes. So the precondition now reads
the wait itself: p1's size waits in more than one spring frame, and one is in
flight at the landing. The test also sends ESC[?2026h in each frame, because
Ghostty ends a hold 1 s after the program's last one.

* revert(next): take back the F3 change (5d62688): no frame shows it, and its red test passed without it

The F3 test (bb46d44) passed at its red commit (fleet step
aef1ba3d3e128b7e7a5b8b01). A parked tab shown mid-spring is unhidden in the
same layout pass in which its area hands it the hold. So no committed frame
shows the stale hold: followedSurfaceFrame ignores a top hold while the host
follows, and a bottom hold is now S3's rule for a low prompt (F2). With no
failing test, the fix does not stay. The test stays as a guard: a parked
terminal shown during a key hold follows its pane from its first frame.

* chore(next): S4 ON review items: debugState is debug-only, frame-owner hot paths marked, contract docs say what each switch state does

From the independent review of #35470 at 665b187 (comment 6635411077),
"Checked" and test findings:
- TerminalFramePresenter.debugState(of:) is under #if DEBUG ||
  CMUX_BENCH_HOOKS, like its only caller, the motion trace sampler.
- place(_:anchor:), requestWantedFrame() and landRequestedFrame() now run in
  each frame of a key resize: they get `// perf-hot: 1ms motion-frame`
  markers (CLAUDE.md: mark new hot paths).
- T3: the doc comments of TerminalHostView+PaneMotion and of the two
  edge-that-stays tests say what they guard with the switch on and off. They
  pass with the switch on because the surface fills its area. They stay as
  guards of the switch-off path and of the low-prompt bottom hold.
No behavior change.

* test(next): cmux-ad m6 (c): TabStripFollowsPaneTickTests (b) with terminals: the strip and the terminal follow the pane in every frame, one grid per press

The lead's m6 order: "change TabStripFollowsPaneTickTests (b) and the K3
tests to the correct contract, red first" ((b) "is a stopgap, not a fix").
(b) checked the strip with a generic content view held at one size per press.
That was the K3 hold that left terminals standing while the tab bar moved
(Lawrence on dogfood 363e39f2). The new case
aKeyResizeKeepsEachStripAndTerminalOnItsPaneInEveryFrame puts a real
terminal in each pane (the app's pane content) and presses each arrow once on
p2. It checks:
- in every frame, each strip is on its pane;
- after each frame's layout pass, each terminal host takes its pane's content
  area and its surface the size the host shows it at;
- each PTY gets one grid, the final one.
The generic Content view now stands for content that still holds through a
key resize (a page until S5, an agent chat). Its doc comment says so.
stray(_:in:) gets an overload that takes the host, the pane content, the
content below the strip and the scale.

Red on feat bb23623 without the S4 ON change (#35470): the terminal
surface keeps the press's final size while its area moves. Green with
#35470 (motion.frameOwner.terminals on, the key-resize follow).

* feat(next): S4 ON PR lands with motion.frameOwner.terminals off by default; the key-resize contract tests turn it on

Lead's order (reversing the accept-with-follow-up): no new red, and a stage
turns ON only when no layer is worse than OFF. The held keys-stress A/B on
cmux24 (job c33bd85dbb064376b348d0c2) misses more refreshes with ON in 5 of
6 rounds (44 OFF / 55 ON). So this PR lands the key-resize follow (cmux-ad
m6), the review fixes (F1, F2) and the contract tests with the switch off
by default. The flip goes in its own PR, after the Ghostty fork change cuts
the per-frame cost (one coalesced frame-ready wake, render-target slack), and
only when that A/B is no worse with ON.
- TerminalDragTunables.framesOwnedByCommit: default false. The doc says what
  turns it on.
- PaneGroundCoversPaneTests.ownFrames()/releaseFrames(): the key-resize
  contract tests turn the switch on. The store is process-wide, so an async
  test turns it on again after each await, before its next press or frame.
  Main-actor code between two awaits never interleaves with another test.
  Used by the PaneContentPolicyTests m6 cases (and the F2 and F3 guards),
  KeyboardResizeContentHoldTests and KeyboardResizeReflowAfterLandingTests
  (theTerminalFollowsItsPane...), and TabStripFollowsPaneTickTests
  (aKeyResizeKeepsEachStripAndTerminal...). TerminalKeyResizeFrameTests and
  TerminalSurfaceCommitSizeTests already turn it on.
- The What's New highlight moves to the flip PR: nothing changes for a person
  with the default off.

* test(next): S4 review of #35470 at 447f784 (G1): the newest press's anchor wins over an older surface hold (red)

The independent review at 447f784 (BLOCK, G1): with the switch on,
restAt returns early for a followed host with a .top anchor, so it neither
replaces nor releases an older surface hold. Two effects, one test each:
- aReversePressAfterALowPromptGrowsDownFollowsItsPane: J grows p1 down with a
  low prompt (F2's bottom hold), then K before the landing. The bottom hold
  stayed, so p1's surface kept the J press's final size on the bottom edge
  while the pane shrank.
- aTerminalThatJoinsAKeyHoldGetsOneGridPerPressThroughASecondPress: a
  terminal that joins mid-hold takes the press's final size as a hold before
  its area hands it the follow; a second press left that hold. The landing's
  release sized from it: grid1, grid2, grid1, grid2 for two presses.
Expected red at this commit; green with the fix.

* fix(next): S4 ON: the newest key press's anchor wins over an older surface hold (review of #35470 at 447f784, G1)

With the switch on, restAt returned early for a followed host with a .top
anchor, so an older hold stayed until the landing:
- a low prompt's bottom hold (F2) after a reverse press: p1 kept the earlier
  final size on the bottom edge while it shrank;
- the hold a terminal took when it joined mid-hold, before its area handed it
  the follow: after a second press the landing's release sized from it, so
  the PTY got grid1, grid2, grid1, grid2 for two presses.
restAt now releases the hold (TerminalHostView.releaseSurfaceHold) before it
returns .top. With the switch on, followedSurfaceFrame ignores a .top hold,
so no frame moves; the release only drops the stale state. Red tests:
7abac06.

Also the two comments that described only S3's producer hold
(PaneMotionHold.swift, PaneContentAreaView.swift) now say what the switch
does.

* test(next): S4 G1 (b) test takes the parked-tab path the review named (the new-tab version passed without the fix)

aTerminalThatJoinsAKeyHoldGetsOneGridPerPressThroughASecondPress passed at
its red commit 7abac06 (fleet step 1b0702f8dbf54ae7091c8dad). A tab added
new mid-hold hears the follow in PaneContentAreaView.didAddSubview before its
surface joins the spring, so it never takes a hold. The path in the review's
G1 (b) is a parked tab shown again: it is unhidden before the area hands it
the follow in its next layout pass, so its surface joins with the OFF path's
hold at the final size. The test now parks the second terminal at rest,
shows it again mid-spring, presses a second time, and requires one grid per
press and none after the landing.

* test(next): S4 keeps the default path's one-surface-size-per-press checks, with the switch off

The independent review of #35470 at c4d6c93 (T5, P2) found that the
default path (motion.frameOwner.terminals off) lost its checks when the
m6 contract tests turned the switch on. The base's five checks come back
as their own tests, with the switch pinned off after each await:

- PaneContentPolicyTests: aKeyPressSizesEachSurfaceOnceWithTheSwitchOff
  (4 directions), aHeldKeySizesEachSurfaceOnlyInAPressTurnWithTheSwitchOff,
  aPressAndItsReverseRestsAtTheStartSizeWithTheSwitchOff;
- KeyboardResizeContentHoldTests: theProducerResizesOnceWithTheSwitchOff;
- KeyboardResizeReflowAfterLandingTests: theProducerLaysOutOnceWithTheSwitchOff
  (left, right).

They are the base's tests at bb23623 with new names; they guard
behavior that does not change, so they pass on the base and at the head.
PaneGroundCoversPaneTests.ownFrames takes the value to pin.

* docs(next): S4 comments and the switch's help say what each state does in a key resize

The independent review of #35470 at c4d6c93 (P3 notes) found
comments that state the switch-on behavior as a fact:
- the help of motion.frameOwner.terminals did not say that it changes
  the key resize;
- TabStripFollowsPaneTickTests.Content ("A terminal no longer holds");
- the PaneContentPolicyTests suite header;
- TerminalHostView.releaseSurfaceHold ("The spring landed"), which restAt
  also calls at a press with the switch on;
- TerminalLaunchFrameKeeperTests' resize waits (they pass at the first
  check with the switch off, the default).

Comments and the debug help text only; no behavior changes.

* test(next): S4 key-resize tests run with motion.frameOwner.terminals off and on

The lead (on the review of #35470 at c4d6c93, T5): run the tests with
the switch off and with it on, so both paths are covered. The default
(off) is what ships.

Each test this PR changed that has a meaning in both states now takes
the switch as an argument, pinned after each await:
- PaneContentPolicyTests: the F3 guard (off: at most one new size from
  the frame it shows, then the area's size at the landing), F2 (off: S3's
  bottom hold keeps the same rule), G1 (a) (off: from the reverse press
  each surface keeps one size, p1 its start size), G1 (b) (one grid per
  press in both);
- TabStripFollowsPaneTickTests: the strip-and-terminal test (off: the
  surface takes at most one new size in the press; strips, hosts and
  grids as with it on);
- KeyResizeFirstTickPinTests (both tests), PaneGroundCoversPaneTests (the
  key-press and held-key ground tests), TerminalLaunchFrameKeeperTests
  (the two resize saves).

The m6 contract tests stay switch-on, and the base's one-size checks are
their switch-off counterparts (c0c7f9b). TerminalKeyResizeFrameTests
and TerminalSurfaceCommitSizeTests test the frame owner itself, which
runs only with the switch on.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Swift logic follow-up (tip watcher swift-logic row, STATUS 13027 "SWIFT LOGIC CULPRITS"; lead order).

This PR raised swift_logic_lines by +209 at its merge commit a08c406: scripts/cmux-next/swift-logic-lines.py before and after the merge, with the same table (the tip's, c6be943). The largest declarations of the rise:

  • struct ClosingPanes in Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ClosingPanes.swift: +206 lines counted as Swift logic (no row in plans/cmux-next/swift-logic-classes.tsv)
  • extension ScreenContentView in Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift: +2 lines counted as Swift logic (its row counts it as logic)
  • extension ScreenContentView in Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Rows.swift: +1 lines counted as Swift logic (its row counts it as logic)

plans/cmux-next/layer-ownership.md says Rust owns every state, list and rule and Swift is the platform shell. Please move the new logic of this PR in a follow-up: to Rust (the crate that owns the area; the owner lists in STATUS 12147-12165 name the target per area), or at least out of the app into its package. If a declaration above is platform code (AppKit, Core Animation, OS calls, strings, tokens, DEBUG tooling), reply here and the table gets a PLATFORM row with your reason instead.

The swift-logic row is a report, not a gate: nothing blocks on this comment.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — df840ecd Deployed Nov 3, 2026 by lawrencecchen via wait for same-key tree publisher #107604
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant