Keyboard pane resize: the tab strip moves with its pane in every frame; only the content below it holds (FRAME OWNER S1)

Merged
LA
2d ago
+998-30

Summary

FRAME OWNER REFACTOR stage S1. Lawrence (P0): "the tabbar is not perfectly in sync with the animation".

In a keyboard pane resize (Ctrl-Shift-H/J/K/L) the tab strip of each resizing pane was held at its start size for the whole spring: the moving pane edge cut it ("python3" -> "on3"), and the whole tab jumped 115 px 3-4 frames after the edge rested (film resize-keyboard, FEAT DOGFOOD BUILD 1d8e759e, frames 244-270, still E1). With a held key the first press's hold lasted through every retarget (film px-5fe33a3, ROOT-CAUSE.md P9: 762 px for 1.7 s). At the release the strip got its new x one tick before its new width (trace samples 285/286).

Cause: the key-resize hold (RESIZE KEYS K3, #32635) held the pane's whole content view, strip included.

Now the strip lays out at its pane's rect in every frame tick, in that frame's main-thread turn and Core Animation transaction, and only the content area below it holds:

  • A hold is a value (PaneMotionHold: the edges the held content rides) handed down the pane's view tree. In-process containers follow the pane's rect every frame; only what draws on another thread or in another process keeps its start size. S3 extends the same hook to the terminal host and the browser chrome.
  • PaneHostView (K3 hold only): content that adopts PaneMotionHolding takes the pane's rect every frame; the host hands its content area the edges K3 already picks and lets it go in the release, before notifyPaneMotionSettled. Split, close, zoom and motion.pane.holdContent springs are unchanged; releaseHeldContent is not touched (#33607, S3a, changes it).
  • PaneContentAreaView (new, CmuxNextDesign) is PaneContentView.contentHost: 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.
  • 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), the whole view still holds.
  • The tab-row decision is latched with the hold: while the content area holds, PaneContentView.layout keeps the decision it had at rest (whether a chat's tools are in the row, and their width), so a fit that flips mid-spring changes nothing until the landing; the release lets the area go and lays the pane out, so the row and the content area change in one pass.
  • TabStripView: a viewport change lays the tabs out at once and starts no TabStripMove; a tab closing on its own move keeps it (cx-y2uf5).
  • Film fixture (its own commit, DEBUG and bench builds only): Debug toggle motion.frameOwner.tabOutline draws a 1 device-px #FFFF00 border on every tab.
  • Tab spares (#33136, TabStripView.whenStill): S1 never changes movesInFlight outside moveEnded; with the switch on a strip width change starts no move at all, so the strip goes still sooner, never later.

All behavior is behind the Debug switch motion.frameOwner.tabStrip (DesignTunables, default on per the refactor owner; off films the old path in the same build). Its deletion PR (branch fix/frame-owner-s1-delete-switch) opens when the first FEAT DOGFOOD BUILD with S1 passes its gate, no later than 2026-11-04 16:00Z.

Follow-ups (not in this PR), branch fix/frame-owner-s1b-first-frame: (1) the strip is one frame late in the first frame of a press or a retarget (3-6 frames per film, the acceptance is 0); (2) intra-strip motion (tab open, close, move) on the window's frame clock when it overlaps a pane motion. An earlier commit of this PR began a strip move at the tick's time; both reviews found defects in it (with S2 on, a begin in the future shows the end value first; a closing tab's goesAt measured from now can leave it after its move ends), so it was taken out here and comes back there with a test. Forecast: PR open 2026-11-03 20:00Z.

Testing

Red (the regression tests, committed before the fix, run on the fleet):
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 --ref c884b2afc3464ec142c05f69f3cacd83fc8d3a0f

  • step 45906c61ceaeacad9ce4248d (aws-m4pro-6): FAILED as expected, 9 issues. One key press H/L/K/J: 104-110 strip frames off their pane per press (at the release p2's strip trailing 1140 vs pane 1200). Held key (3 presses, one spring): 140-146. A pane width change during a tab close started a new strip move (generation 2 beside the close's 1). Green there as planned: K/J with top-anchored content, and the guard (split, close, ratio, divider release, column width springs).

Green at the head b75090d0b38c20b07dd83aecde287ec84065aed2: step ce73dc3b4cd68b6a9cea5b48 (aws-m4pro-3) passed 20 of 20 suites: TabStripFollowsPaneTickTests, PaneContentAreaHoldTests, PaneTabRowHoldLatchTests, the K3 and S3a hold suites (KeyboardResizeContentHoldTests, KeyboardResizeHoldLifetimeTests, KeyboardResizeOneGridReportTests with the switch on and off, PaneHeldContentLandingTests, PaneMotionContentHoldTests) and the pane content, toolbar, parking, tab-row and launch suites.
GUI proof on a display worker at the head: step 6e4aca8988ec6c1e51bd4cbe (--display --label gui-session-v1, cmux12s-Mac-mini, 1 screen) passed 7 of 7 (the S1 suites, TabCloseWidthChangeTests, the K3 and S3a hold suites).

Performance

TabStripFollowsPaneTickBench (CMUX_KEY_RESIZE_STRIP_BENCH=1, release, never in CI): main-thread time of each display frame of keyboard resizes, 3 panes x 6 tabs over stub content, 12 presses x 5 rounds, switch off (old path) and on interleaved in one process.

metricswitch offswitch ondeltarun
frame p500.054 ms0.188 ms+0.13 msstep 321ba04adf2ae8d6f0a0ddd9 (aws-m4pro-6, the head 07f254ac6b2)
frame p950.066 ms0.276 ms+0.21 mssame
frame p990.267 ms0.282 ms+0.02 mssame
frame max0.290 ms0.303 ms+0.01 mssame

Each frame there counts advanceFrame, the layout pass, the views' display and the layers' display that the commit runs (re-truncated tab titles). An earlier run without the layers' display (step 796c551916519c57f11c79bb, d1d4dd41a73) gave nearly the same numbers (on: p50 0.181, p95 0.267 ms). Budget: 4 ms per 120 Hz frame. Stub content only: the terminal, page and chat stay held as before, so their cost does not change.

Typing under load (the refactor owner's pair, typing-load-l6 with the P0 runner flags, cmux18), key-to-commit ms p50 / p95 / p99 / max: base FEAT DOGFOOD BUILD 03f6f649 3.13 / 9.37 / 15.54 / 23.68; this PR (build beb296be of 07f254ac6b2) 3.25 / 9.65 / 13.61 / 21.16. PASS: p50 and p95 within noise, p99 and max lower.

Film proof (120 Hz)

The refactor owner's A/B on one mini (cmux18, 120 Hz 2x, fixture kit 77a62dcfe69): base FEAT DOGFOOD BUILD 03f6f649 (feat c7cef08a37c6), run ab-s1-base-c7cef08-20261103T133748Z-cmux18; this PR, build beb296be of 07f254ac6b2, run ab-s1-pr-07f254a-20261103T145047Z-cmux18. judge_pixel counts frames where a tab strip edge is off its pane edge by 1 px or more (x1 speed):

filmbasethis PR
px-keys-single-3k-s1r (single key presses)150 frames, max 115 px3 frames, max 6 px
px-keys-held-2-s1r (held keys)297 frames, max 637 px4 frames, max 12 px

The frames that are left are the first frame of a press or a retarget, where the strip is one frame late (frame 241 in keys-single; 423 and 448 in held). A follow-up PR fixes them. Reduce Motion: no change between base and PR. Dropped refreshes during the motion (judge_trace), base vs PR: keys-single 3 vs 1 (Reduce Motion 6 vs 6), held 3 vs 6 (Reduce Motion 8 vs 12); 20 vs 25 over the four films is not conclusive. Repeat of px-keys-held-2-s1r: drops base 1/9, S1 0/8 (x1 / Reduce Motion): no cost; the strip there: base 288 frames off (max 637 px), S1 6 frames (max 12 px).

Observed / Not run

  • Observed: the red and green fleet steps above; check-motion.sh ok; prepush-gate.py no new violations (arch-ratchet app_lines -3).
  • TabSelectionKeepsTabWidthsTests is red on feat itself (base run 75fa7f0facb92db46b5c855d at f093ac3ebb34; cause 9d31a6eb78b), not from this PR; fixed in its own PR (linked in a comment).
  • TabCloseWidthChangeTests needs a display: it skips in a suite step and passed in the GUI step above.
  • Films: both arms above (GREEN for the tab strip), filmed at 07f254ac6b2. typing-load-l6: PASS (above). At the head the strip path for a pane-width change is the same: with the switch on, a width change runs relayout(animated: false) and starts no TabStripMove, so the begin-time code removed after the films never ran in them. The tab-row latch changes only a chat that shares the tab row, which no filmed pane is. Both are covered by tests (PaneTabRowHoldLatchTests, red at 5dd61722956: step 0ecb04c677e6b65c40ae7806), not by films.
  • Red on the base of the latch: step 0ecb04c677e6b65c40ae7806 (5dd61722956) failed as expected: from frame 5 of the spring the held chat was offered the row (28 pt) and the content area grew over it.
  • Not run: px-col-drag-2-s1r in the PR arm (ScreenCaptureKit did not start); S1 changes no drag path.

Changelog

Fixed: in a keyboard pane resize the tab bar moves with its pane in every frame (it was held, then jumped)

Proof

No capture: the defect and the fix are frame timing in a 120 Hz motion, which stills cannot show. The proof is the 120 Hz film A/B above.


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


Summary by cubic

Fixes the tab strip in keyboard pane resizes (Ctrl-Shift-H/J/K/L) so it moves with its pane in every frame instead of being held at its start size and jumping after the edge rests.

The key-resize hold now applies only to the content area below the strip: a new PaneContentAreaView keeps its subviews at their start size and shows them on the held edges through its bounds origin, while the strip lays out at the pane's width in the same frame tick. At the release, each subview takes the area's size once, before the content hears that the motion ended; that release order composes with S3a's layOutLetGoContent. A strip width change no longer starts a tab move of its own; a tab close in flight keeps its own move. A page's tab-row fit decision (a chat drawing its tools in the row) is latched with the hold, so it does not flip mid-spring; at the landing the row and the content area change in one layout pass. In a browser's band, and when a page draws its tools in the tab row, the whole view still holds (owned by a later stage).

  • Behind the Debug switch motion.frameOwner.tabStrip (on by default); the switch off films the old path in the same build.
  • Adds a Debug-only film fixture (motion.frameOwner.tabOutline) drawing a 1 device-px yellow border on every tab.
  • Adds regression tests (TabStripFollowsPaneTickTests, PaneContentAreaHoldTests, PaneTabRowHoldLatchTests; KeyboardResizeOneGridReportTests now covers both hold shapes) and a release-only cost bench (TabStripFollowsPaneTickBench); main-thread frame time rises about 0.13 ms p50 on stub content.