fix(cmux-next): stop the pane revealing a page that is still deliberately blank

Closed
TE
10d ago
+100-10

Leo, 2026-10-22: "the initial load is kinda slow it like flickers the initial thing/paints and
renders it... and then it just goes to black screen for a while?"

#24262 fixes the flicker — the pane taking back the chat's last frame while its page was still
coming. It narrows the black screen from about 7 s to about 2 s but cannot close it, because the
rest of the gap is two budgets that disagree. This closes that.

The two budgets

The pane's loading state gives up on a page that has not reported a first paint after two checks in
sight (about 8 s) and shows the page as it is. That give-up is there for a page that cannot
report: WebKit holds the animation frames of a window it thinks is not visible, so pane.painted
may never come at all (cx-txnr).

But the agent page also holds its own first paint back for up to CHAT_DRAW_HOLD_MS — 10 s after
its handshake — while it waits for the chat's rows. So the host was revealing, at 8 s, a page that
intended to stay blank until 10 s. That is Leo's black screen: the pane's own frame goes, and what
replaces it is a page deliberately showing nothing.

The page's 10 s hold is not the thing to shorten. Its own comment records why it is 10 s: a
shorter hold showed a busy daemon's chat as an empty page with only its composer — Leo, 2026-10-21,
"I LOAD IN AND IT'S A BLACK SCREEN". Lowering it walks back into the bug it was raised to fix. So
the host's bound is the one that moves.

What changes

A page that answered the handshake is not the stuck case: its bridge is up, and it reports by its
own deadline whether the chat arrived or not. It now gets aliveChecks checks rather than
silentChecks, counted from the handshake — the first check that sees it answer starts the
count over. That detail matters: a bound measured from the pane's creation still fires early when a
slow daemon answers late in the silent window, which is exactly the case that hurts. A page that
says nothing at all keeps today's bound, unchanged.

No protocol change: the host already knows, from model.hasHandshake.

The cost is that a page which handshakes and then truly hangs holds its spinner longer than before.
That is the right way round — a spinner says loading, a black pane says broken — and it is still
bounded.

Reproduce and repair

cmux-ci run --script scripts/ci/package-test-lane.sh --arg suite \
  --arg Packages/macOS/CmuxNext --arg AgentPaneColdChatRevealTests

Three tests, each doing a different job:

  • aHandshakedPageKeepsItsLoadingStatePastTheSilentBound is the regression: it is red with this
    change reverted, because base reveals the page at the silent bound.
  • aHandshakedPageStillShowsAsItIsAtItsOwnBound is the canary. It passes on base too — its job is
    to fail if the check timer never fires in the test host at all, which would make the one above
    pass vacuously. The two drive the same timer.
  • theAliveBoundClearsThePagesOwnBudget reads CHAT_DRAW_HOLD_MS out of App.tsx instead of
    restating it, so raising either side alone fails here rather than bringing the gap back quietly.
    A renamed or unreadable constant fails too, rather than passing vacuously.

Observed / Not run

Observed: python3 scripts/cmux-next/prepush-gate.py reports no new violations.
pr-overlap read 428 open PRs and found none touching this.

Not run: the red leg has not executed yet, and I am not going to call it a receipt before it
has. Both CI routes for a CmuxNext suite are deeply queued today — a light agent-class fleet step
for this package sat unassigned for 2.5 h behind 220 jobs, and cmux-next swift test on hosted
macOS has been queued over an hour on #24357. The same focused command above, with this commit's
source change reverted and the tests kept, is the red leg; I will post it here when it runs. If it
comes back green the test does not bite and it gets reworked before this lands.

Also not run: any live launch. The gap needs a cold launch against a loaded daemon to show itself,
and both timings here are read from source rather than filmed. Say the word and I will film a cold
launch before and after on a capture slot.

No capture: this is a timing change on a native pane, and a still cannot show a gap between two
frames.

Changelog

Fixed: the agent pane no longer shows a blank page for a few seconds when a chat is slow to open.

Leo-ask: ask-665f9ad8ba

🤖 Generated with Claude Code

Agent session: big-red


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


Summary by cubic

Fixes the black-screen gap on cold chat loads by stopping the agent pane from revealing a page that is deliberately still blank. The pane gave up on an unpainted page after about 8 seconds, but a page that answered the handshake holds its first paint for up to 10 seconds while waiting for chat rows, so it revealed a page that intended to show nothing.

Bug Fixes

  • A page that answered the handshake now gets three checks, counted from the handshake, before it is revealed; a page that never answers keeps the old bound.
  • A test reads the page's CHAT_DRAW_HOLD_MS from App.tsx and requires the host bound to clear it, so neither side can drift back into a gap.
  • A handshaked page that genuinely hangs keeps its spinner longer, which is preferred over showing a broken blank pane.