fix(ssh): repaint a reconnected full-screen app from the grid

A reconnected TUI came back as fragments of a frame — Claude Code showed a few
rules and its cost line until a resize made it repaint itself.

Relay replay is a byte TAIL. It can begin mid-escape and it misses the
alt-screen enter, the clears and the absolute positioning that built the frame,
so replaying it into the fresh xterm a remount just created paints whatever
fragments survive. Main already keeps the thing that does restore a frame: a
real @xterm/headless grid, alt-screen aware, fed unconditionally for SSH. Local
terminals already repaint from it; SSH was the only path that did not.

So this routes an SSH reconnect into the painter that already exists, at the one
expression that chooses model over tail. No new call site, no second lifecycle,
and every existing gate still applies — a null, renderer-sourced or empty
snapshot still degrades to the tail, so it cannot paint blank.

ONLY ON THE ALTERNATE SCREEN, and that is the whole design. The reconnect replay
reaches the renderer without passing through main's model — forwardReattachReplay
and the inline attach replay both bypass onPtyData — so at that moment the model
is stale by exactly the outage. For a full-screen app that trade is right: a tail
cannot rebuild a frame it no longer contains, a grid can, and the SIGWINCH the
restore already sends makes the app redraw the delta. For a scrolling shell it
would be wrong: the tail holds output the model never saw, and preferring the
grid would drop it for good. A park has no such hole, so it keeps using the model
either way.

Derived from the PENDING retry, not directSshRetryAttempt. That also matches the
live binding, which is written at the same tab generation once a reconnect
succeeds and then outlives it — so it stays truthy for every later remount of
that generation. Reading it directly is what made my first attempt fire on mounts
that were not reconnects. Consumed alongside mountFollowsTerminalPark for the
same reason.

Verified live against the reported app: Claude Code restores identical to its
pre-disconnect frame, top restores coherent and live, and a plain shell still
shows output written before the disconnect. 5,470 tests pass across the touched
suites.

Not the whole story, and the remaining half is already written down: the model's
gap exists because the SSH reattach asks for a tail instead of participating in
the checkpointed resume the relay already implements. Close that and this paint
is not merely coherent but exactly correct, for shells too.
This commit is contained in:
Neil
2026-08-15 02:32:01 -07:00
parent 09e1d8c189
commit b46f521f24
@@ -3485,6 +3485,17 @@ export function connectPanePty(
: undefined
return attempt
})()
// Only the PENDING retry marks a mount that a reconnect created. directSshRetryAttempt also
// accepts the live binding, which is written at the same tab generation once the reconnect
// succeeds and then outlives it — so it stays truthy for every later remount of that generation,
// not just this one.
const followsDirectSshReconnect = (() => {
const pending = state.directSshPaneRetryByTabId?.[deps.tabId]
return (
pending?.authority.targetId === connectionId &&
pending.tabGeneration === (tab?.generation ?? 0)
)
})()
const pendingSpawnKey = directSshRetryAttempt
? JSON.stringify([cacheKey, directSshRetryAttempt.attemptId])
: cacheKey
@@ -8209,6 +8220,12 @@ export function connectPanePty(
const revealFollowsTerminalPark =
mountFollowsTerminalPark &&
(connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId))
// An SSH reconnect remounts the pane (tab.generation is its React key), so it also paints into
// a fresh xterm — but unlike a park it may only use the model for a FULL-SCREEN app. See the
// alternate-screen gate below for why. Consumed with the flag above: directSshRetryAttempt
// stays true for the whole tab generation, not just this mount, so reading it directly would
// re-arm on every later remount.
const reconnectMayUseModel = followsDirectSshReconnect && !revealFollowsTerminalPark
mountFollowsTerminalPark = false
// Why: ordinary parking destroys xterm. Rebuild from the authoritative
// host snapshot before releasing queued live bytes; null falls back to
@@ -8310,10 +8327,24 @@ export function connectPanePty(
// model still holds, but an in-place reattach (network reconnect, wake,
// reload) already has that replay in hand, so probing would only delay its
// paint by the timeout. Memoized, so this is never a second probe.
const modelSnapshot = revealFollowsTerminalPark
// Why an SSH reconnect may only use the model on the ALTERNATE SCREEN: the reconnect replay
// reaches the renderer without passing through main's model (forwardReattachReplay and the
// inline attach replay both bypass onPtyData), so at this moment the model is stale by
// exactly the outage. For a full-screen app that is the right trade — a byte tail cannot
// rebuild a frame it no longer contains, while a grid can, and the SIGWINCH the restore
// already sends makes the app repaint the delta itself. For a scrolling shell it is the
// wrong trade: the tail holds output the model never saw, and preferring the grid would
// drop it for good. A park has no such hole, so it keeps using the model either way.
const revealSnapshot = revealFollowsTerminalPark
? (prefetchedParkModelSnapshot ??
(isRemoteRuntimePtyId(ptyId) ? null : await fetchSshMainModelReattachSnapshot()))
: null
const reconnectSnapshot =
!revealSnapshot && reconnectMayUseModel
? await fetchSshMainModelReattachSnapshot()
: null
const modelSnapshot =
revealSnapshot ?? (reconnectSnapshot?.alternateScreen ? reconnectSnapshot : null)
if (!isCurrentReattachPayload()) {
return
}