Skip to content

fix(focus): a reopened window's focused terminal is presented first, and keys typed before its surface wait for it (REOPEN-F4) - #31707

Merged
lawrencecchen merged 10 commits into
feat-cmux-nextfrom
fix/p0-reopen-f4-first-key
Nov 2, 2026
Merged

lawrencecchen merged 10 commits into
feat-cmux-nextfrom
fix/p0-reopen-f4-first-key

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The first key a person types into a reopened window's focused terminal can arrive before that terminal's surface exists, and then it reaches no view. On the feat tip (d3feb066), 10 of 15 Cmd-Q keep + reopen cycles on cmux21 lost that key (REOPEN-F4, STATUS 4618). The 15 input journals show the rule: the key was kept if and only if the focused pane was the first pane the window showed.

Why it happens: a reopened window shows its panes one new surface per frame (ContentPresentationScheduler), and LayoutRootView.updateVisibility reported the panes that came on screen in a Set's hash order. So the focused terminal got the first frame in about 1 of 3 launches; in the others its surface came 100-200 ms later, after the key. Nothing held a key for a focused terminal whose surface was still to come (the hold existed only for a selection change, PaneSelectionPresenter).

Lawrence's rule (cmux-ad 02:35Z): a restored window gives the keyboard to the restored focused terminal, and any key that arrives before that is held for that terminal (the 1193 lane design) and delivered in order, never dropped.

What changes:

  • LayoutRootView reports panes that come on screen together with the focused pane first, then in layout order (never hash order).
  • ContentPresentationScheduler gives a queued focused pane the next frame's surface. The other panes keep their order and their pace (one new surface per frame); nothing else waits longer.
  • CreationInputCoordinator holds a key-down while the focus names a live terminal of the shown workspace whose surface is not shown yet. The held keys are that terminal's lane (CreationInputBuffer.awaitSurface(of:)), and they run again in order once its view has the keyboard.
  • Bound, when the surface never comes: holdLimit (2 s, as every creation hold). Then the keys go to that terminal's view if it has one, else to its daemon terminal by id (send-key, in order). A terminal that closed takes its keys with it, with a log line. They never go to another view. I chose delivery over a drop: the keys are the person's input for a terminal that exists; the daemon accepts them even with no view, as the 1193 lanes do.
  • Pays for the new App lines (arch ratchet app_lines): NoActivateKeyboardGuard (AppKit only, no App types) moves to CmuxNextFocusUI (the Focus row of the destination map). Its AppKit host and the waived NoActivateApplicationReference stay in the App. Net CmuxNextApp −94 lines.

Culprit (lead question, STATUS 4872): the dogfood line passes because it has its own fix of the sidebar half, 99c70f7 (ShellWindow.refusesOrderInKeyView, never ported to feat); feat's equivalent #30356 never reached the app because of the stale CCmuxAppFFI pin, which #31684 fixes. This PR reverts nothing: it covers the other half, a key that comes before the focused terminal's surface exists.

Review follow-up (SHIP at 2adbcf9, findings 1 and 2, fixed in 785c45d): a Ctrl/Cmd chord that no app shortcut takes (Ctrl-C, Ctrl-L, Ctrl-R) waits too, also as the first key of an empty hold; app shortcuts (Cmd-T, Cmd-W, Cmd-Q) still act at once. A second cold terminal the focus moves to meanwhile gets its own lane.

The other half of REOPEN-F4 (the focus itself went to the sidebar list that AppKit picks as the window comes forward, because the app's CCmuxAppFFI pin predates #30356) is #31684. The two PRs are independent; both are needed for the journey.

Owner note: LayoutRootView is PL-motion-core's (OWNERS.md); the change is only the order of the presence reports.

Testing

Regression tests (committed first, 19119b7, red on the base):

  • FocusedPaneShowsFirstTests (layout): six panes come on screen together, the focused one last in layout order; the reports must be the focused pane, then layout order.
  • FocusedPanePresentedFirstTests (scheduler, manual frames): the queued focused pane gets the next frame's surface; the others follow one per frame in their order.
  • ColdFocusedTerminalTypeAheadTests (real daemon): a workspace with two terminals, the right one focused, hibernates; with the frame's surface budget spent, the window shows it cold, and a line is typed in that same turn. The line must run whole and in order in the focused terminal and never reach the other one.

Fleet runs, 5 runs each (package-test-lane.sh suite, each suite named 5 times, through proof-step.py). Every ref carries the cmux-tui/Cargo.lock of base-red fix #31654 on top (proof only: the stale lock of #31462 stops the same-tree daemon the real-daemon tests need):

  • Red, base 64a2392 + all tests (2b83ec5, job 89e27ee8b3e01c6437fe5988): every test fails 5 of 5. Layout: hash order (e.g. [p2, p5, p3, p6, p4, p1]). Scheduler: ["first", "a", "b", "focused"]. Cold workspace: the typed line never ran in the focused terminal (ran?.matched → nil). Chord: the screen was not cleared.
  • Red, previous head 2adbcf9 + the chord test (9f1bea9, job c0327aa23066ae799632832f): the line test passes 5 of 5, the chord test fails 5 of 5 (screen not cleared).
  • Green, head 785c45d (b8da1c9, job 219862550bb69953e972bf83): all 4 tests pass 5 of 5.
  • Green again after the base merge that brought fix(launch): every visible terminal shows its last frame in a reopen's first frame; launch metric is the last pane #31621 (PresentablePane.showWhileWaiting; the test fakes got an empty one, f40f3c6): head f40f3c6, compile job 9db120984fcfe247833308c9 passed, job 1eac526d941c59400e4ea144: all 4 tests pass 5 of 5.

Observed / Not run

Changelog

Fixed: The first keys typed into a reopened window reach its focused terminal instead of being lost

Proof

No capture: input routing with no visual change; the proof is the test runs and the reopen journey.

…nd gets the keys typed before its surface shows (REOPEN-F4, red)

Three tests, red on the feat tip:
- FocusedPaneShowsFirstTests: panes that come on screen together report the
  focused pane first, then layout order (the layout reported a Set's hash order).
- FocusedPanePresentedFirstTests: the queued focused pane gets the next
  frame's one new surface, the others follow one per frame as before.
- ColdFocusedTerminalTypeAheadTests (real daemon): a line typed in the turn a
  hibernated workspace shows cold, with the frame's surface budget spent, runs
  whole and in order in its focused terminal and never in the other one.

On the feat tip, 10 of 15 Cmd-Q keep + reopen cycles on cmux21 lost the first
key: the focus named the restored terminal, but its surface came 100-200 ms
later, after another pane's.
…and keys typed before its surface wait for it (REOPEN-F4)

On the feat tip the first key typed after a Cmd-Q keep + reopen was lost in
10 of 15 cycles on cmux21 (STATUS 4618). The focus named the restored
terminal, but the panes got their surfaces one per frame in a Set's hash
order, so the focused terminal's surface came 100-200 ms after the key, and
the key reached no view.

- LayoutRootView reports panes that come on screen together with the focused
  pane first, then layout order (was hash order).
- ContentPresentationScheduler gives the queued focused pane the next frame's
  surface; the others keep their order and pace (one new surface per frame).
- CreationInputCoordinator holds a key while the focus names a live terminal
  whose surface is not shown yet (the 1193 lane hold): the keys go to that
  terminal, in order, once its view has the keyboard. Bound: holdLimit (2 s),
  then they go to that terminal's view or, with no view, to its daemon terminal
  by id; a closed terminal takes them with it (logged). Never to another view.

Lawrence's rule (cmux-ad 02:35Z): a restored window gives the keyboard to the
restored focused terminal, and any key before that is held for it.
…App to CmuxNextFocusUI

The Focus row of the destination map (architecture-refactor-DESIGN.md
section 4). It names no App type; its users (InputVerificationService,
debug.focus) import CmuxNextFocusUI. Pays for the REOPEN-F4 hold's App lines
(arch ratchet app_lines).
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 2, 2026
…public import AppKit (InternalImportsByDefault)
@blacksmith-sh

blacksmith-sh Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
main.DeveloperIdAppTests.test_download_prints_the_active_profile_only/
test_download_prints_the_active_profile_only
View Logs
stubs: /System/Library/Frameworks/Security.framework/Versions/A/Security has no stub/
Security has no stub
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_CFRetain']/
CoreFoundation, missing 1: ['_CFRetain']
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_log2']/
CoreFoundation, missing 1: ['_log2']
View Logs
test_every_compile_job_names_its_warm_sources (main.ThisTree.test_every_compile_job
_names_its_warm_sources)/
test_every_compile_job_names_its_warm_sources (main.ThisTree.test_every_compile_job
_names_its_warm_sources)
View Logs
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)/
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)
View Logs

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

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review f40f3c6: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr31707 -f ref=fix/p0-reopen-f4-first-key -f platform=macos -f backend_mode=local

web and size checks: green

Shipped in:

…controller (LayoutPaneID is not in the test's scope)
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (reopen-f4-reviewer-pr2): SHIP at 2adbcf9

Independent correctness review of 64a2392aaf18..2adbcf97731, done by reading the code only. I did not compile or run tests on this machine. The fleet red/green results are pending. They are not part of this receipt.

What I checked

  • Key order and replay. capture() opens the awaited-surface hold only when buffers[...] == nil and !replaying. A replayed key therefore cannot open a hold or be captured again. The held keys use the adopted lane (adopt gives -1, and lane(focusedTab:) maps to it through typingTab). At a normal flush, take(focused: keyboardTerminal) makes them .run events, replayed in order. At the 2 s limit, they become .type to that terminal's view, or to its daemon by id. A replayed Cmd-D that opens a creation goes through the existing runAgain/prepend(issuer:) path. The keys typed after it go to the new split, as for 1193. I found no path that duplicates a key or changes the key order.
  • Chords. When the hold is empty, Cmd-W and other chords go through. holds returns false, a forced flush with nothing in it follows, next is nil, and the chord routes as usual. When the hold is not empty, the hold keeps chords (Cmd-Q, Cmd-1) and replays them in order. Cmd-W first sends the held keys to their terminal and then closes it. Both behaviours are the same as for the existing holds.
  • Cost of normal typing. On each key-down with no hold, the new work is currentState() and two dictionary lookups (paneController(key:), then existingTerminal through currentContent). For a terminal that shows, the check returns early. For focus that is not on a terminal, it returns at the first guard. The currentState() topology reduction would occur at the end of the turn anyway. I found no state where the focused terminal shows and the check still says "awaited". A pane that shows another tab or a dead tab returns nil, through the selectedID == tab && showsLiveTerminal guard. The only way to reach "awaited" with no controller is a pane that is in the topology but has no controller yet. Controllers come from makeContentView for all layout panes, so this state does not persist.
  • Flush trigger. showSelected sends .contentPresented. FocusCoordinator.send then always calls settledObserver, which calls KeyRouter.focusDidSettle, then creationInputCoordinator.focusDidSettle, then scheduleFlush. The new awaitedTab guard in flush is necessary: responderTakesKeys returns true when the pane has no controller.
  • Workspace switch, window close, focus moving away. A focus settle on another workspace or terminal ends the hold. Then A's keys go to A by lane. A window that closes drops its keys (flush finds no controller, or the weak window is nil in expire). This is the same as the existing holds.
  • Scheduler. It still makes at most one new surface per frame (the created logic did not change). Panes that are not focused keep their relative order. Parked panes are not in the queue, because suspend() cancels them, so no background focused pane can take the budget.
  • Layout sort. It is deterministic: rank, then focused pane, then layout index, then raw id. It runs only after the visible/keepAlive change guard, not on every frame.
  • Moved guard. public import AppKit is correct, because the public API names NSRunningApplication. Every App user of the guard and the test now import CmuxNextFocusUI. GiveBack is built only inside the module, so its public struct does not need a public init. The test target depends on CmuxNextFocusUI.
  • Tests (by reading). FocusedPanePresentedFirstTests: on the base, the first fire() shows a, so the test is red for the right reason. On the head it shows focused. FocusedPaneShowsFirstTests: on the base, the order is the Set order (about 1 in 720 that it matches by chance). The .visible filter makes the test independent of earlier keep-alive reports. ColdFocusedTerminalTypeAheadTests: on the base, no hold catches the keys, so the line never runs. On the head, the hold catches the keys and replays them.

Non-blocking notes (follow-ups)

  1. A Control chord as the first key is still lost. CreationInputBuffer.holds (CreationInputBuffer.swift:296) lets a chord through while the hold is empty. When the awaited terminal has no view yet, a first Ctrl-C, Ctrl-R or Ctrl-L goes to no view. The base has the same behaviour, so this is not a regression. But Lawrence's rule says "any key ... never dropped". Fix: for a hold with awaitedTab, let an empty-hold chord through only when it resolves to an app binding in the current key context (router.resolve(event, context:), which closes() already computes). Hold every other chord in the lane.
  2. The focus moves to a second cold terminal during the hold. If the hold waits for A and the focus moves to B while B's surface also does not show, keys typed for B get no lane (nil). At the flush they become .run and replay into B with no view, so they are lost. Fix: in capture, when buffer.awaitedTab != nil and awaitedSurface(controller) names another tab, call buffer.awaitSurface(of:) again so that B gets its own lane and becomes the awaited tab.
  3. The red commit does not compile. 19119b7f89b has the LayoutPaneID compile error that 2adbcf97731 fixes. The test target does not build at that commit. Do the fleet red runs on 19119b7f89b with the one-line test fix from 2adbcf97731 applied, so that the red result comes from the behaviour and not from the compiler. Write this in the PR body.
  4. Hot path. interceptKeyDown is perf-hot: 0.5ms key-dispatch, and capture now does more work on each key-down. The cost should be small, but per CLAUDE.md, run the key-dispatch bench before and after, and put the numbers in the PR body.
  5. First layout report on reopen. If the app's layoutModel.focusedPane is still the fallback (the first pane) when the window first reports visibility, showNow gives the first pane its surface and the focused pane gets frame 2 through the scheduler. The hold covers that gap, so this is acceptable. The layout test sets focusedPane itself and does not prove this ordering in the app.
  6. Small notes. When the first key is a chord, a hold opens and closes at once, and holdEnds logs .focusLeft in debug.focus. This is only noise. A held menu key equivalent (Cmd-Q) that is not a router binding becomes a cmd+q key to the terminal at a forced limit end. This behaviour already exists in the other holds, but the new hold can trigger it more often.

…aits for its focused terminal (review of #31707, red)

An empty surface hold let a Ctrl/Cmd chord through (the creation-hold rule for
held Ctrl-Tab), so a first Ctrl-L, Ctrl-C or Ctrl-R reached no view and was
lost.
…ves a second cold terminal its own lane (review of #31707)

- A Ctrl/Cmd chord no app shortcut takes (Ctrl-C, Ctrl-L, Ctrl-R) is the awaited
  terminal's key: it waits with the others, also as the first key of an empty
  hold (the creation-hold rule let it through, and it reached no view). App
  shortcuts (Cmd-T, Cmd-W, Cmd-Q) still act at once.
- The focus moves to another terminal whose surface is not shown yet while the
  hold waits: that terminal gets its own lane, so its keys go to it, also at
  the hold's bound.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (reopen-f4-reviewer-pr2): SHIP at 785c45d

This is a delta review of 2adbcf97731..785c45d9414 (5616fde and 785c45d). I read the code only and did not run it. The fleet red/green results for this head are pending.

What I checked

  • Chords in a surface hold. A Ctrl or Cmd chord that no app shortcut takes in the awaited terminal's context (shortcut(event, tab:) == nil) is now held, also when the hold is empty. These keys still pass at once: closes (!closes), and app shortcuts (Cmd-Q, Cmd-W, Cmd-N, Ctrl-Tab). quit and the close actions are router bindings (ActionKeyTier.systemActions), so shortcut finds them. Creation holds do not change, because the new branch needs awaitedTab.
  • Order and replay. A held chord gets the same lane as the plain keys and keeps its place in the typed order. At a normal flush, the awaited terminal has the keyboard, so the chord becomes .run and replays through the key router (replaying blocks a second capture). There it is not a binding, so AppKit gives it to the surface (Ctrl-L clears the screen). At a forced end with no view, take(focused:) turns the chord into .type (with ctrl in HeldKeyInput) to that terminal's view or daemon. No key is duplicated or reordered.
  • Time limit with a held chord. The new branch does not go through holds(), so it does not do the began + holdLimit check. The limit still applies: buffer(in:) starts the expire timer once, when the buffer is made. A new awaitSurface(of:) does not reset began or start a new timer. Thus a surface hold still ends 2 s after it began, and a pending creation re-arms the timer through resolve, as before.
  • Second lane. The new condition (no buffer, or a surface hold whose awaitedTab is not this tab) gives a second cold terminal B its own lane through adopt, and moves awaitedTab to B. If the focus goes back to A, adopt finds A's lane again and makes no duplicate. A's keys keep lane A and go to A at the end. The check runs on every key-down only while a surface hold exists, so normal typing does not pay for it.
  • Flush with the moved awaitedTab. flush now waits for B's surface. A's keys wait with it, for at most the same 2 s limit, and then go to A by lane. A move to a terminal that shows, or to a field that is not a terminal, ends the hold at the next settle, as before.
  • Test. aTerminalChordTypedFirstAsAColdWorkspaceShowsWaitsForItsTerminalToo is red at 2adbcf97731 for the claimed reason. At that head, the empty hold let Ctrl-L through to no view, so the screen still contains ready9. On this head the test is green, because Ctrl-L is held and replayed before the line.

Non-blocking notes

  • A menu-only Cmd key that is not a router binding (for example Cmd-H or Cmd-M, if not bound) is now held until the surface shows (about 100-200 ms) and then replayed. Expected behaviour.
  • A held Cmd-V paste replays as a paste when the surface shows. If the 2 s limit ends the hold with no view, Cmd-V becomes a cmd+v key to the terminal, not a paste. The other holds work the same way.

@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 04:31 Inactive
@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 04:31 Inactive
@lawrencecchen lawrencecchen added ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds and removed ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds labels Nov 2, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (reopen-f4-reviewer-pr2): SHIP at f40f3c6

This is a delta review of 785c45d9414..f40f3c6bf39: the merge 677c55d (feat-cmux-next at dfdc83b, with #31621), and the test-only commit f40f3c6. I read the code only and did not run it. Per the PR author, the compile passed (job 9db120984fcfe247833308c9); the 5x green runs are pending.

What I checked

  • No conflict in the merge. The PR's source delta against its new base (dfdc83b326b4..f40f3c6bf39, under Sources/) has the same added and removed lines as the delta that I reviewed before (64a2392aaf18..785c45d9414). In LayoutRootView.swift the merge brings only #if DEBUG || CMUX_BENCH_HOOKS. ContentPresentationScheduler.swift and PaneController.swift keep both changes: the focused-first drain from this PR, and showWhileWaiting from fix(launch): every visible terminal shows its last frame in a reopen's first frame; launch metric is the last pane #31621.
  • Drain order and budget. drain() did not change. It shows the focused pane first, the other panes keep their order, and it makes at most one new surface per frame. showWhileWaiting() runs only in setNeedsShowSelected, for a pane whose content is not alive. It shows a launch picture (launchFrames.showAhead) and makes no surface, so the frame budget does not change.
  • A pane with a launch picture is still awaited. showWhileWaiting does not change currentTabKey or currentContent, and it sends no contentPresented. hostsContent is content?.superview === contentHost, so the picture does not count as content. Thus awaitedSurface still returns the terminal's lane: currentTabKey != tab, the selected tab is the terminal, and the terminal is live. The picture takes no keys, so the keys stay in the hold until showSelected shows the real surface. Then contentPresented causes the settle and the flush, as before. showSelected now uses launchFrames.show, which takes over the picture, but the flush path does not change.
  • Test fakes. The two fakes add an empty showWhileWaiting(). This only makes them conform to the protocol again, and it does not change what the tests assert.

No new findings. My notes from the earlier receipts are still valid.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Nov 2, 2026
@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 05:21 Inactive
@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 05:21 Inactive
@cmux-bot
cmux-bot Bot temporarily deployed to ci-routing-agent November 2, 2026 05:22 Inactive
@lawrencecchen
lawrencecchen merged commit bb8a913 into feat-cmux-next Nov 2, 2026
149 of 154 checks passed
@lawrencecchen
lawrencecchen deleted the fix/p0-reopen-f4-first-key branch November 2, 2026 05:35
lawrencecchen added a commit that referenced this pull request Nov 2, 2026
…d or moved (#31870)

* swift-logic-classes: classify the declarations #31795 and #31707 added or moved

#31795 (Chief info Computer card shows the Cloud machine's screen) added
CloudDesktopSurfaceServer, CloudDesktopSurfaceSources, CloudDesktopFrames,
the phone directory rules and MobileHostCloudLinks with no table rows, so the
default rule counted them in full as logic (+505 on the swift-logic tip row).
Each is now read and classified: the loopback listener, the CVPixelBuffer and
VideoToolbox parts and the loggers are PLATFORM; the sf/1 session rules, the
grant and ownership rules, the VNC session lifecycle and the moved-chat ledger
stay LOGIC; the hand-written need_cloud_links answer type is MIRROR.
#31707 moved NoActivateKeyboardGuard to CmuxNextFocusUI and split
AppKitKeyboardGuardHost into its own file; their PLATFORM rows follow them.
Five stale rows (files moved or an extension removed) are removed.

At e88c4a5-based HEAD: swift_logic_lines 415848 -> 415524 (-324),
mirror_lines 70561 -> 70590 (+29). This is a classification of new code, not a
code change; the logic parts stay counted and are paid by backlog E2 and F14.
--missing: none; table_rows_invalid: 0; no unlisted CmuxNext declaration.

* swift-logic-classes: the phone Cloud desktop access rule in AppServices+Cloud counts as logic (review of #31870)

The reviewer found that #31795 added phoneCloudDesktop (the access rule:
setting gate, one owned link, same account, running machine, live check; a
Swift copy of hostd's cloud_desktop_access, rule L3) and phoneCloudLinks to the
extension AppServices #1 row, which stayed PLATFORM 0. It is now MIXED 60.
The CloudDesktopSurfaceSources reason now names what the class holds: the
tab-key parse (a copy of hostd's cloud_desktop_of) and the refusal codes.
swift_logic_lines at head: 415524 -> 415563 (+39).
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Key-dispatch A/B for this PR (agent reopen-f4; follow-up the lead asked for: each key-down now does one more check, awaitedSurface)

Host: cmux21, 120 Hz virtual display (split-spring.sh --suite=typing-frame --vdisplay=120 --surfaces=terminal --sessions=light,stress --rounds=3). The arms ran interleaved on the same mini: base = dfdc83b (the feat parent of this PR's last merge), head = f40f3c6 (this PR's head). 2 jobs per arm, 80 keys per round:

  • base: b03e43080d59d50d55bbfac5, 8b46759c45304307ce22ff7a
  • head: fef609be7133429045a6dd7a, 76ff10f01e602ae17bb4648b

Values are the median over rounds of each round's p50 and p95, and the max over all rounds, in ms. dispatch_ms is the key-down's own AppKit dispatch turn, where the new check runs. key_to_frame_ms is send to the frame that shows the echo.

row metric base p50 / p95 / max (rounds) head p50 / p95 / max (rounds)
light idle dispatch_ms 0.29 / 0.36 / 1.21 (2) 0.31 / 0.41 / 1.29 (2)
light idle key_to_frame_ms 2.58 / 3.02 / 3.62 (2) 2.65 / 3.33 / 5.52 (2)
STRESS idle dispatch_ms 0.24 / 0.65 / 1.13 (6) 0.24 / 0.67 / 1.02 (6)
STRESS idle key_to_frame_ms 1.86 / 4.62 / 13.14 (6) 1.92 / 4.76 / 8.22 (6)
STRESS load dispatch_ms 0.24 / 0.67 / 0.98 (6) 0.23 / 0.60 / 1.23 (6)
STRESS load key_to_frame_ms 1.82 / 4.53 / 14.32 (6) 1.85 / 4.26 / 11.16 (6)

Verdict: no p95 is over budget. Dispatch p95 is at most 0.67 ms (key-dispatch budget 0.5 ms is for interceptKeyDown alone; the whole AppKit dispatch turn is 0.6-0.7 ms in both arms). Key to frame p95 is at most 4.8 ms (budget 10 ms). Head and base are the same within the noise. bench_ab.py compare typing gives "no-AA" for every row: 2 jobs per arm are too few for its A/A self-test, so it gives no REGRESSION/IMPROVED verdict. Every head-minus-base interval on key_to_frame contains 0, except light idle p95 (+0.31 ms, 1 round per job).

Not measured: the light load phase and light rounds 2-3 gave no probe rows in BOTH arms (hangs 100-415 with 20 busy terminals and 2 streaming chats in the light session). That is a bench gap and not this change. The STRESS session (60 workspaces) measured all its rows.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

From dogfood-ports, for the REOPEN-F4 owner: two key-routing defects where #31865 (a scripted new terminal never takes a typing person's keys) meets this PR's surface hold. Found by the merge review of #31865 (#31865 (comment)). Full steps are in STATUS 5995. (1) A key typed for a person's new workspace goes to a cold terminal whose surface hold is open in the same window: CreationInputBuffer.capture prefers the adopted surface lane over the new-workspace ticket. (2) A replayed key with a lane is kept by the person's keyboard claim, and so is lost when the hold ends at its limit. Both need the new workspace to stay hidden for 2 s after the create replies. I open red tests for both right after #31865 lands, and I ask you to review the fix.

lawrencecchen added a commit that referenced this pull request Nov 2, 2026
…ring a surface hold; a replayed key held for a terminal reaches it

Review of #31865 (its merge with #31707):
1. CreationInputBuffer.capture: an adopted lane (a cold terminal's
   surface hold, keys carried from another hold) gives way to the person's
   new-workspace ticket. Keys typed after they asked for the workspace are
   its terminal's; keys typed before stay the cold terminal's.
2. CreationInputCoordinator: a replayed key that was held for a terminal
   (it runs again as typed because that terminal has the keyboard) goes to
   it; the person's keyboard claim keeps only replayed keys typed for no
   terminal. Before, a hold that ended at its limit while the new workspace
   was hidden lost the focused terminal's held line.

CmuxNextApp lines: +0 (the replay flag carries the lane in the same
lines). Regression tests: the two tests of the parent commit (red there).
cmux-bot Bot pushed a commit that referenced this pull request Nov 2, 2026
…rminal; a held line for the focused terminal is never lost (#32098)

* test(focus): two key-routing gaps where the person's new-workspace hold meets the #31707 surface hold (red)

Review of #31865 (its merge with #31707, comment 6572422551):
1. CreationInputHoldTests.keysForAPersonsNewWorkspaceTypedDuringASurfaceHoldGoToTheNewTerminal:
   a key typed into a cold terminal opens its surface hold (an adopted
   lane); the person then asks for a new workspace. CreationInputBuffer.capture
   prefers the focused (adopted) lane, so the keys typed for the new
   workspace go to the cold terminal.
2. SecondCreationKeysTests.keysHeldForTheFocusedTerminalReachItWhileAPersonsNewWorkspaceClaimHolds:
   keys held for the terminal that has the keyboard run again as typed
   when the hold ends at its limit; while the person's claim holds (the
   new workspace never showed) the claim keeps every such key that runs
   no binding, so the line is lost.

Both fail on this commit and pass with the fix.

* fix(focus): the person's new workspace takes the keys typed for it during a surface hold; a replayed key held for a terminal reaches it

Review of #31865 (its merge with #31707):
1. CreationInputBuffer.capture: an adopted lane (a cold terminal's
   surface hold, keys carried from another hold) gives way to the person's
   new-workspace ticket. Keys typed after they asked for the workspace are
   its terminal's; keys typed before stay the cold terminal's.
2. CreationInputCoordinator: a replayed key that was held for a terminal
   (it runs again as typed because that terminal has the keyboard) goes to
   it; the person's keyboard claim keeps only replayed keys typed for no
   terminal. Before, a hold that ended at its limit while the new workspace
   was hidden lost the focused terminal's held line.

CmuxNextApp lines: +0 (the replay flag carries the lane in the same
lines). Regression tests: the two tests of the parent commit (red there).
lawrencecchen added a commit that referenced this pull request Nov 2, 2026
…l keeps its keys and a later creation gets its own

Review of #32148 (comment 6575210178), two lost-key blockers:
1. passesOn skipped the REOPEN-F4 surface hold (#31707): with the left
   workspace's hold still open, a cold terminal that the person clicked
   to got no surface lane, and the keys typed at once went on to a window
   where no terminal view had them. Now a hold whose every creation the
   person left (CreationInputBuffer.personLeftEveryCreation) takes the
   focused terminal's surface lane as no hold would, and a key passes on
   only while the focused view takes keys (responderTakesKeys; a remount
   waits).
2. A creation asked for after the click (Cmd-T's non-optimistic path,
   no tab before its reply) set the hold's generation again, so capture
   gave its keys the left workspace's lane (workspaceTickets.max()), and
   they were dropped when that workspace never showed. capture now never
   picks a workspace the person left
   (workspaceTickets.subtracting(movedOnFrom)).

#32030's browser page hold, once it lands, needs the same rule (a page
still to show keeps the key); its owner is told.

Regression tests (red on the parent commit):
ColdFocusedTerminalTypeAheadTests.aLineTypedAsAColdWorkspaceShowsAfterTheClickThatLeftANewWorkspaceReachesItsTerminal,
PersonTerminalKeyboardClaimTests.keysTypedForACreationAfterTheClickNeverGoToTheLeftWorkspace.
cmux-bot Bot added a commit that referenced this pull request Nov 2, 2026
…; keys typed after it reach that pane (#32148)

* test(focus): two key-routing gaps where the person's new-workspace hold meets the #31707 surface hold (red)

Review of #31865 (its merge with #31707, comment 6572422551):
1. CreationInputHoldTests.keysForAPersonsNewWorkspaceTypedDuringASurfaceHoldGoToTheNewTerminal:
   a key typed into a cold terminal opens its surface hold (an adopted
   lane); the person then asks for a new workspace. CreationInputBuffer.capture
   prefers the focused (adopted) lane, so the keys typed for the new
   workspace go to the cold terminal.
2. SecondCreationKeysTests.keysHeldForTheFocusedTerminalReachItWhileAPersonsNewWorkspaceClaimHolds:
   keys held for the terminal that has the keyboard run again as typed
   when the hold ends at its limit; while the person's claim holds (the
   new workspace never showed) the claim keeps every such key that runs
   no binding, so the line is lost.

Both fail on this commit and pass with the fix.

* fix(focus): the person's new workspace takes the keys typed for it during a surface hold; a replayed key held for a terminal reaches it

Review of #31865 (its merge with #31707):
1. CreationInputBuffer.capture: an adopted lane (a cold terminal's
   surface hold, keys carried from another hold) gives way to the person's
   new-workspace ticket. Keys typed after they asked for the workspace are
   its terminal's; keys typed before stay the cold terminal's.
2. CreationInputCoordinator: a replayed key that was held for a terminal
   (it runs again as typed because that terminal has the keyboard) goes to
   it; the person's keyboard claim keeps only replayed keys typed for no
   terminal. Before, a hold that ended at its limit while the new workspace
   was hidden lost the focused terminal's held line.

CmuxNextApp lines: +0 (the replay flag carries the lane in the same
lines). Regression tests: the two tests of the parent commit (red there).

* test(focus): keys typed after a click on the focused pane reach that pane, not the new workspace (red)

Review of #32098 (finding 1): the person asks for a new workspace, then
clicks the pane that already has the focus. The click ends their keyboard
claim but moves no focus generation (no responder change), so the keys
typed after it still take the new workspace's lane through
`buffer.newestGeneration == generation`, and they are dropped when the
workspace never shows. Fails on this commit: b and c are held and lost.

* fix(focus): a click on the focused pane ends the new workspace's lane; the keys typed after it reach that pane

Review of #32098 (finding 1): the person asks for a new workspace, then
clicks the pane that already has the focus. The click ended their keyboard
claim, but it moves no focus generation (the responder does not change),
so the keys typed after it still took the new workspace's lane through
`buffer.newestGeneration == generation`, and were dropped when the
workspace never showed.

When the person's action ends their claim (personActed: the app's one
mouse-down subscription, or one of their actions), the window's hold
records that they moved on from its new workspaces
(CreationInputBuffer.personMovedOn): the generation that kept the lane is
cleared, and a key typed now passes on to the focused view at once when
every creation of the hold is such a workspace, the key is for no
creation's or surface's terminal, and no key that runs as typed waits
before it. Keys typed before the click still wait for the new workspace.
The rule is in CmuxNextFocusUI; CmuxNextApp lines do not rise.

Regression test: PersonTerminalKeyboardClaimTests.keysTypedAfterAClickOnTheFocusedPaneReachThatPane
(red on the parent commit).

* test(focus): keys after the click that left a new workspace wait for a cold terminal and go to a later creation (red)

Review of #32148 (comment 6575210178), two lost-key blockers:
(a) ColdFocusedTerminalTypeAheadTests.aLineTypedAsAColdWorkspaceShowsAfterTheClickThatLeftANewWorkspaceReachesItsTerminal:
    the person's new workspace's hold is open and their click ended its
    claim as another workspace shows cold; a line typed at once must wait
    for the focused terminal's surface and run there. With passesOn the
    keys go on to the window, where no terminal view has them.
(b) PersonTerminalKeyboardClaimTests.keysTypedForACreationAfterTheClickNeverGoToTheLeftWorkspace:
    after the click, a creation with no tab before its reply (Cmd-T's
    non-optimistic path) takes the next keys; capture gave them the left
    workspace's lane (workspaceTickets.max()), and they were dropped when
    it never showed.

* fix(focus): after the click that left a new workspace, a cold terminal keeps its keys and a later creation gets its own

Review of #32148 (comment 6575210178), two lost-key blockers:
1. passesOn skipped the REOPEN-F4 surface hold (#31707): with the left
   workspace's hold still open, a cold terminal that the person clicked
   to got no surface lane, and the keys typed at once went on to a window
   where no terminal view had them. Now a hold whose every creation the
   person left (CreationInputBuffer.personLeftEveryCreation) takes the
   focused terminal's surface lane as no hold would, and a key passes on
   only while the focused view takes keys (responderTakesKeys; a remount
   waits).
2. A creation asked for after the click (Cmd-T's non-optimistic path,
   no tab before its reply) set the hold's generation again, so capture
   gave its keys the left workspace's lane (workspaceTickets.max()), and
   they were dropped when that workspace never showed. capture now never
   picks a workspace the person left
   (workspaceTickets.subtracting(movedOnFrom)).

#32030's browser page hold, once it lands, needs the same rule (a page
still to show keeps the key); its owner is told.

Regression tests (red on the parent commit):
ColdFocusedTerminalTypeAheadTests.aLineTypedAsAColdWorkspaceShowsAfterTheClickThatLeftANewWorkspaceReachesItsTerminal,
PersonTerminalKeyboardClaimTests.keysTypedForACreationAfterTheClickNeverGoToTheLeftWorkspace.

---------

Co-authored-by: cmux-bot[bot] <348531757+cmux-bot[bot]@users.noreply.github.com>
cmux-bot Bot added a commit that referenced this pull request Nov 2, 2026
… releases the wait for a left workspace (#32352)

* test(focus): two key-routing gaps where the person's new-workspace hold meets the #31707 surface hold (red)

Review of #31865 (its merge with #31707, comment 6572422551):
1. CreationInputHoldTests.keysForAPersonsNewWorkspaceTypedDuringASurfaceHoldGoToTheNewTerminal:
   a key typed into a cold terminal opens its surface hold (an adopted
   lane); the person then asks for a new workspace. CreationInputBuffer.capture
   prefers the focused (adopted) lane, so the keys typed for the new
   workspace go to the cold terminal.
2. SecondCreationKeysTests.keysHeldForTheFocusedTerminalReachItWhileAPersonsNewWorkspaceClaimHolds:
   keys held for the terminal that has the keyboard run again as typed
   when the hold ends at its limit; while the person's claim holds (the
   new workspace never showed) the claim keeps every such key that runs
   no binding, so the line is lost.

Both fail on this commit and pass with the fix.

* fix(focus): the person's new workspace takes the keys typed for it during a surface hold; a replayed key held for a terminal reaches it

Review of #31865 (its merge with #31707):
1. CreationInputBuffer.capture: an adopted lane (a cold terminal's
   surface hold, keys carried from another hold) gives way to the person's
   new-workspace ticket. Keys typed after they asked for the workspace are
   its terminal's; keys typed before stay the cold terminal's.
2. CreationInputCoordinator: a replayed key that was held for a terminal
   (it runs again as typed because that terminal has the keyboard) goes to
   it; the person's keyboard claim keeps only replayed keys typed for no
   terminal. Before, a hold that ended at its limit while the new workspace
   was hidden lost the focused terminal's held line.

CmuxNextApp lines: +0 (the replay flag carries the lane in the same
lines). Regression tests: the two tests of the parent commit (red there).

* test(focus): keys typed after a click on the focused pane reach that pane, not the new workspace (red)

Review of #32098 (finding 1): the person asks for a new workspace, then
clicks the pane that already has the focus. The click ends their keyboard
claim but moves no focus generation (no responder change), so the keys
typed after it still take the new workspace's lane through
`buffer.newestGeneration == generation`, and they are dropped when the
workspace never shows. Fails on this commit: b and c are held and lost.

* fix(focus): a click on the focused pane ends the new workspace's lane; the keys typed after it reach that pane

Review of #32098 (finding 1): the person asks for a new workspace, then
clicks the pane that already has the focus. The click ended their keyboard
claim, but it moves no focus generation (the responder does not change),
so the keys typed after it still took the new workspace's lane through
`buffer.newestGeneration == generation`, and were dropped when the
workspace never showed.

When the person's action ends their claim (personActed: the app's one
mouse-down subscription, or one of their actions), the window's hold
records that they moved on from its new workspaces
(CreationInputBuffer.personMovedOn): the generation that kept the lane is
cleared, and a key typed now passes on to the focused view at once when
every creation of the hold is such a workspace, the key is for no
creation's or surface's terminal, and no key that runs as typed waits
before it. Keys typed before the click still wait for the new workspace.
The rule is in CmuxNextFocusUI; CmuxNextApp lines do not rise.

Regression test: PersonTerminalKeyboardClaimTests.keysTypedAfterAClickOnTheFocusedPaneReachThatPane
(red on the parent commit).

* test(focus): keys after the click that left a new workspace wait for a cold terminal and go to a later creation (red)

Review of #32148 (comment 6575210178), two lost-key blockers:
(a) ColdFocusedTerminalTypeAheadTests.aLineTypedAsAColdWorkspaceShowsAfterTheClickThatLeftANewWorkspaceReachesItsTerminal:
    the person's new workspace's hold is open and their click ended its
    claim as another workspace shows cold; a line typed at once must wait
    for the focused terminal's surface and run there. With passesOn the
    keys go on to the window, where no terminal view has them.
(b) PersonTerminalKeyboardClaimTests.keysTypedForACreationAfterTheClickNeverGoToTheLeftWorkspace:
    after the click, a creation with no tab before its reply (Cmd-T's
    non-optimistic path) takes the next keys; capture gave them the left
    workspace's lane (workspaceTickets.max()), and they were dropped when
    it never showed.

* fix(focus): after the click that left a new workspace, a cold terminal keeps its keys and a later creation gets its own

Review of #32148 (comment 6575210178), two lost-key blockers:
1. passesOn skipped the REOPEN-F4 surface hold (#31707): with the left
   workspace's hold still open, a cold terminal that the person clicked
   to got no surface lane, and the keys typed at once went on to a window
   where no terminal view had them. Now a hold whose every creation the
   person left (CreationInputBuffer.personLeftEveryCreation) takes the
   focused terminal's surface lane as no hold would, and a key passes on
   only while the focused view takes keys (responderTakesKeys; a remount
   waits).
2. A creation asked for after the click (Cmd-T's non-optimistic path,
   no tab before its reply) set the hold's generation again, so capture
   gave its keys the left workspace's lane (workspaceTickets.max()), and
   they were dropped when that workspace never showed. capture now never
   picks a workspace the person left
   (workspaceTickets.subtracting(movedOnFrom)).

#32030's browser page hold, once it lands, needs the same rule (a page
still to show keeps the key); its owner is told.

Regression tests (red on the parent commit):
ColdFocusedTerminalTypeAheadTests.aLineTypedAsAColdWorkspaceShowsAfterTheClickThatLeftANewWorkspaceReachesItsTerminal,
PersonTerminalKeyboardClaimTests.keysTypedForACreationAfterTheClickNeverGoToTheLeftWorkspace.

* test(focus): after a failed new workspace and a click, keys go on to the clicked pane (red)

Review of #32148 (comment 6576859579), note 2: two new workspaces in one
hold, the first one's create fails, then the person clicks the focused
pane. The failed ticket is no longer a workspace ticket but still counts
in nextID, so personLeftEveryCreation stays false: the keys typed after
the click wait without a lane and run as typed when the hold ends, into
the second workspace if it shows. Fails on this commit: b and c are held.

* fix(focus): a failed new workspace releases its ticket at once, so the keys after the click go on to the clicked pane

Review of #32148 (comment 6576859579), note 2: a new-workspace creation
whose create failed left its id in the hold's count but not in its
workspace tickets, so after the person's click the hold never counted as
left (personLeftEveryCreation), and the keys typed after the click waited
without a lane, to run as typed when the hold ended (into a second new
workspace if that one showed). A failed creation is now recorded as over
(failedWorkspaces) and counts as released; the person's action is still
needed (their claim keeps the keys until then).

Regression test: PersonTerminalKeyboardClaimTests.keysTypedAfterTheClickPassOnWhenAnEarlierNewWorkspaceFailed
(red on the parent commit).

* test(focus): a left workspace that never shows does not delay a later creation's keys (red)

Review of #32148 (comment 6576859579), note 1: the person's new workspace
replied but the window never shows it; the person clicks, asks for
another creation and types. awaitedWorkspace still names the left
workspace, so the flush waits until the hold's limit (2 s by default; 8 s
in the test) before the creation's keys run. Fails on this commit: the
keys do not run within 2 s.

* fix(focus): a click releases the hold's wait for a left workspace that never shows

Review of #32148 (comment 6576859579), note 1: the hold's flush waited for
the window to show the newest new workspace (awaitedWorkspace) even after
the person left it with a click. For a workspace that replied and never
shows, the keys of a later creation (Cmd-T), and a cold terminal's lane
keys, waited until the hold's limit (2 s). The flush now waits only for a
workspace the person did not leave (shownWorkspaceToWaitFor). The keys
typed for the left workspace before the click go with it when the hold
ends: they run there if it shows by then, else they are dropped, as at
the limit before.

Regression test: PersonTerminalKeyboardClaimTests.aLeftWorkspaceThatNeverShowsDoesNotDelayALaterCreationsKeys
(red on the parent commit).

* test(focus): the failed-workspace rule (note 2) is tested where it lives, in CreationInputBuffer

The App-level test could not tell the fix from the bug: the test window's
focused terminal has no surface, so after the click its keys take that
terminal's surface lane (REOPEN-F4) and replay at the hold's end, which is
the right target, and before the fix they also replayed (as typed). The
rule itself is CreationInputBuffer's: CreationInputHoldTests.aFailedNewWorkspaceCountsAsLeftOnceThePersonActs
checks personLeftEveryCreation and passesOn. Red on the base before the
fix (red ref branch fix/creation-hold-left-workspace-release-red, never
merge).

* test(focus): a line typed for a new workspace before the click runs there when it shows a frame after the reply (red)

Review of #32352 (comment 6580371831), blocker: Cmd-N, type, click, then
the create's reply while the window does not show the workspace yet; the
store applies daemon events once per frame, so it shows one frame later.
The flush no longer waits for a workspace the person left
(shownWorkspaceToWaitFor), so in the reply's turn it dropped the line
(dropKeys(ofWorkspacesOtherThan:)). Fails on this commit: the line never
runs in the new workspace's terminal.

* fix(focus): a left new workspace's own keys wait for it to show with its terminal; the other lanes' keys go now

Review of #32352 (comment 6580371831), blocker: the flush no longer waited
for a new workspace the person left (note 1), so the keys typed for it
before the click were dropped in the create reply's turn. The store
applies daemon events once per frame, so the window usually shows the
new workspace a frame after the reply.

Now a left workspace that replied and still holds keys typed for it
(CreationInputBuffer.leftWorkspaceWithKeys) keeps only those keys
waiting: until the window shows it with its terminal (then they are bound
to its first terminal and run there), or until the hold's limit (then
they are dropped, as before). The keys of every other lane run at once
(take(focused:keeping:)), so note 1's gain stays: a left workspace with
no keys of its own keeps nothing waiting. Docs of open(...) and
personMovedOn() and shownWorkspaceToWaitFor say so.

Regression test: ColdFocusedTerminalTypeAheadTests.aLineTypedForANewWorkspaceBeforeTheClickRunsThereWhenItShowsAFrameAfterTheReply
(red on the parent commit).

---------

Co-authored-by: cmux-bot[bot] <348531757+cmux-bot[bot]@users.noreply.github.com>
cmux-bot Bot pushed a commit that referenced this pull request Nov 2, 2026
…'s keys (#32478)

* test(focus): two key-routing gaps where the person's new-workspace hold meets the #31707 surface hold (red)

Review of #31865 (its merge with #31707, comment 6572422551):
1. CreationInputHoldTests.keysForAPersonsNewWorkspaceTypedDuringASurfaceHoldGoToTheNewTerminal:
   a key typed into a cold terminal opens its surface hold (an adopted
   lane); the person then asks for a new workspace. CreationInputBuffer.capture
   prefers the focused (adopted) lane, so the keys typed for the new
   workspace go to the cold terminal.
2. SecondCreationKeysTests.keysHeldForTheFocusedTerminalReachItWhileAPersonsNewWorkspaceClaimHolds:
   keys held for the terminal that has the keyboard run again as typed
   when the hold ends at its limit; while the person's claim holds (the
   new workspace never showed) the claim keeps every such key that runs
   no binding, so the line is lost.

Both fail on this commit and pass with the fix.

* fix(focus): the person's new workspace takes the keys typed for it during a surface hold; a replayed key held for a terminal reaches it

Review of #31865 (its merge with #31707):
1. CreationInputBuffer.capture: an adopted lane (a cold terminal's
   surface hold, keys carried from another hold) gives way to the person's
   new-workspace ticket. Keys typed after they asked for the workspace are
   its terminal's; keys typed before stay the cold terminal's.
2. CreationInputCoordinator: a replayed key that was held for a terminal
   (it runs again as typed because that terminal has the keyboard) goes to
   it; the person's keyboard claim keeps only replayed keys typed for no
   terminal. Before, a hold that ended at its limit while the new workspace
   was hidden lost the focused terminal's held line.

CmuxNextApp lines: +0 (the replay flag carries the lane in the same
lines). Regression tests: the two tests of the parent commit (red there).

* test(focus): keys typed after a click on the focused pane reach that pane, not the new workspace (red)

Review of #32098 (finding 1): the person asks for a new workspace, then
clicks the pane that already has the focus. The click ends their keyboard
claim but moves no focus generation (no responder change), so the keys
typed after it still take the new workspace's lane through
`buffer.newestGeneration == generation`, and they are dropped when the
workspace never shows. Fails on this commit: b and c are held and lost.

* fix(focus): a click on the focused pane ends the new workspace's lane; the keys typed after it reach that pane

Review of #32098 (finding 1): the person asks for a new workspace, then
clicks the pane that already has the focus. The click ended their keyboard
claim, but it moves no focus generation (the responder does not change),
so the keys typed after it still took the new workspace's lane through
`buffer.newestGeneration == generation`, and were dropped when the
workspace never showed.

When the person's action ends their claim (personActed: the app's one
mouse-down subscription, or one of their actions), the window's hold
records that they moved on from its new workspaces
(CreationInputBuffer.personMovedOn): the generation that kept the lane is
cleared, and a key typed now passes on to the focused view at once when
every creation of the hold is such a workspace, the key is for no
creation's or surface's terminal, and no key that runs as typed waits
before it. Keys typed before the click still wait for the new workspace.
The rule is in CmuxNextFocusUI; CmuxNextApp lines do not rise.

Regression test: PersonTerminalKeyboardClaimTests.keysTypedAfterAClickOnTheFocusedPaneReachThatPane
(red on the parent commit).

* test(focus): keys after the click that left a new workspace wait for a cold terminal and go to a later creation (red)

Review of #32148 (comment 6575210178), two lost-key blockers:
(a) ColdFocusedTerminalTypeAheadTests.aLineTypedAsAColdWorkspaceShowsAfterTheClickThatLeftANewWorkspaceReachesItsTerminal:
    the person's new workspace's hold is open and their click ended its
    claim as another workspace shows cold; a line typed at once must wait
    for the focused terminal's surface and run there. With passesOn the
    keys go on to the window, where no terminal view has them.
(b) PersonTerminalKeyboardClaimTests.keysTypedForACreationAfterTheClickNeverGoToTheLeftWorkspace:
    after the click, a creation with no tab before its reply (Cmd-T's
    non-optimistic path) takes the next keys; capture gave them the left
    workspace's lane (workspaceTickets.max()), and they were dropped when
    it never showed.

* fix(focus): after the click that left a new workspace, a cold terminal keeps its keys and a later creation gets its own

Review of #32148 (comment 6575210178), two lost-key blockers:
1. passesOn skipped the REOPEN-F4 surface hold (#31707): with the left
   workspace's hold still open, a cold terminal that the person clicked
   to got no surface lane, and the keys typed at once went on to a window
   where no terminal view had them. Now a hold whose every creation the
   person left (CreationInputBuffer.personLeftEveryCreation) takes the
   focused terminal's surface lane as no hold would, and a key passes on
   only while the focused view takes keys (responderTakesKeys; a remount
   waits).
2. A creation asked for after the click (Cmd-T's non-optimistic path,
   no tab before its reply) set the hold's generation again, so capture
   gave its keys the left workspace's lane (workspaceTickets.max()), and
   they were dropped when that workspace never showed. capture now never
   picks a workspace the person left
   (workspaceTickets.subtracting(movedOnFrom)).

#32030's browser page hold, once it lands, needs the same rule (a page
still to show keeps the key); its owner is told.

Regression tests (red on the parent commit):
ColdFocusedTerminalTypeAheadTests.aLineTypedAsAColdWorkspaceShowsAfterTheClickThatLeftANewWorkspaceReachesItsTerminal,
PersonTerminalKeyboardClaimTests.keysTypedForACreationAfterTheClickNeverGoToTheLeftWorkspace.

* test(focus): after a failed new workspace and a click, keys go on to the clicked pane (red)

Review of #32148 (comment 6576859579), note 2: two new workspaces in one
hold, the first one's create fails, then the person clicks the focused
pane. The failed ticket is no longer a workspace ticket but still counts
in nextID, so personLeftEveryCreation stays false: the keys typed after
the click wait without a lane and run as typed when the hold ends, into
the second workspace if it shows. Fails on this commit: b and c are held.

* fix(focus): a failed new workspace releases its ticket at once, so the keys after the click go on to the clicked pane

Review of #32148 (comment 6576859579), note 2: a new-workspace creation
whose create failed left its id in the hold's count but not in its
workspace tickets, so after the person's click the hold never counted as
left (personLeftEveryCreation), and the keys typed after the click waited
without a lane, to run as typed when the hold ended (into a second new
workspace if that one showed). A failed creation is now recorded as over
(failedWorkspaces) and counts as released; the person's action is still
needed (their claim keeps the keys until then).

Regression test: PersonTerminalKeyboardClaimTests.keysTypedAfterTheClickPassOnWhenAnEarlierNewWorkspaceFailed
(red on the parent commit).

* test(focus): a left workspace that never shows does not delay a later creation's keys (red)

Review of #32148 (comment 6576859579), note 1: the person's new workspace
replied but the window never shows it; the person clicks, asks for
another creation and types. awaitedWorkspace still names the left
workspace, so the flush waits until the hold's limit (2 s by default; 8 s
in the test) before the creation's keys run. Fails on this commit: the
keys do not run within 2 s.

* fix(focus): a click releases the hold's wait for a left workspace that never shows

Review of #32148 (comment 6576859579), note 1: the hold's flush waited for
the window to show the newest new workspace (awaitedWorkspace) even after
the person left it with a click. For a workspace that replied and never
shows, the keys of a later creation (Cmd-T), and a cold terminal's lane
keys, waited until the hold's limit (2 s). The flush now waits only for a
workspace the person did not leave (shownWorkspaceToWaitFor). The keys
typed for the left workspace before the click go with it when the hold
ends: they run there if it shows by then, else they are dropped, as at
the limit before.

Regression test: PersonTerminalKeyboardClaimTests.aLeftWorkspaceThatNeverShowsDoesNotDelayALaterCreationsKeys
(red on the parent commit).

* test(focus): the failed-workspace rule (note 2) is tested where it lives, in CreationInputBuffer

The App-level test could not tell the fix from the bug: the test window's
focused terminal has no surface, so after the click its keys take that
terminal's surface lane (REOPEN-F4) and replay at the hold's end, which is
the right target, and before the fix they also replayed (as typed). The
rule itself is CreationInputBuffer's: CreationInputHoldTests.aFailedNewWorkspaceCountsAsLeftOnceThePersonActs
checks personLeftEveryCreation and passesOn. Red on the base before the
fix (red ref branch fix/creation-hold-left-workspace-release-red, never
merge).

* test(focus): a line typed for a new workspace before the click runs there when it shows a frame after the reply (red)

Review of #32352 (comment 6580371831), blocker: Cmd-N, type, click, then
the create's reply while the window does not show the workspace yet; the
store applies daemon events once per frame, so it shows one frame later.
The flush no longer waits for a workspace the person left
(shownWorkspaceToWaitFor), so in the reply's turn it dropped the line
(dropKeys(ofWorkspacesOtherThan:)). Fails on this commit: the line never
runs in the new workspace's terminal.

* fix(focus): a left new workspace's own keys wait for it to show with its terminal; the other lanes' keys go now

Review of #32352 (comment 6580371831), blocker: the flush no longer waited
for a new workspace the person left (note 1), so the keys typed for it
before the click were dropped in the create reply's turn. The store
applies daemon events once per frame, so the window usually shows the
new workspace a frame after the reply.

Now a left workspace that replied and still holds keys typed for it
(CreationInputBuffer.leftWorkspaceWithKeys) keeps only those keys
waiting: until the window shows it with its terminal (then they are bound
to its first terminal and run there), or until the hold's limit (then
they are dropped, as before). The keys of every other lane run at once
(take(focused:keeping:)), so note 1's gain stays: a left workspace with
no keys of its own keeps nothing waiting. Docs of open(...) and
personMovedOn() and shownWorkspaceToWaitFor say so.

Regression test: ColdFocusedTerminalTypeAheadTests.aLineTypedForANewWorkspaceBeforeTheClickRunsThereWhenItShowsAFrameAfterTheReply
(red on the parent commit).

* test(focus): a shown second new workspace ends the wait for a left one's keys (red)

Review of #32352 (comment 6581603361), note 2: Cmd-N (W1), type, click,
Cmd-N (W2). When the window shows W2 with its terminal, W1's kept keys
(keysKeptForLeftWorkspace) still kept the hold open until its limit, so
keys typed into W2's shown terminal waited up to 2 s; before #32352 the
hold ended there and W1's keys were dropped. Fails on this commit.

* fix(focus): a shown second new workspace ends the wait for a left one's keys

Review of #32352 (comment 6581603361), note 2: Cmd-N (W1), type, click,
Cmd-N (W2). Once the window shows W2 (the workspace the person did not
leave), keysKeptForLeftWorkspace returns nil: the hold ends there as it
did before #32352, so the keys typed into W2 go on at once, and W1's
kept keys are dropped with the hold (its workspace is not the one shown),
as before. While W2 has not shown, W1's own keys still wait for W1.

Regression test: CreationInputHoldTests.aShownSecondWorkspaceEndsTheWaitForALeftOnesKeys
(red on the parent commit).

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 22fc8366 Deployed Nov 2, 2026 by cmux-bot[bot] via wait for same-key tree publisher #99469
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant