mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
refactor(pty-connection): extract reattach payload handlers as factories
- Replace bindApplyReattachPayload with createReattachPayloadHandlers factory that returns handlers instead of mutating session directly, enabling better composability and testing - Extract waitForUserInitiatedSshConnect as standalone function for reuse across deferred session attach flows - Create ReattachPayloadSession type to document and isolate required session capabilities - Add test coverage for overlapping reattach payload attempts - Clean up comments to remove redundant prefixes (session.pane → pane, session.transport → transport)
This commit is contained in:
+9
-2
@@ -210,7 +210,14 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
const reattachArgs = transport.connect.mock.calls.find(
|
||||
([args]) => args.sessionId === 'lost-pty'
|
||||
)?.[0]
|
||||
const launchToken = (reattachArgs?.env as Record<string, string> | undefined)
|
||||
?.ORCA_AGENT_LAUNCH_TOKEN
|
||||
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(launchToken).toMatch(new RegExp(`^${UUID_RE}$`))
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: 'lost-pty',
|
||||
@@ -221,13 +228,13 @@ describe('connectPanePty', () => {
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'wt-1',
|
||||
ORCA_WORKSPACE_ID: 'wt-1',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
|
||||
ORCA_AGENT_LAUNCH_TOKEN: launchToken
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, {
|
||||
agentType: 'codex',
|
||||
launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)),
|
||||
launchToken,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF_1
|
||||
})
|
||||
|
||||
@@ -22,14 +22,17 @@ import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
import type { ReattachPayloadSession } from './reattach-payload-session'
|
||||
|
||||
export function bindApplyReattachPayload(
|
||||
session: ConnectPanePtySession,
|
||||
export function createReattachPayloadHandlers(
|
||||
session: ReattachPayloadSession,
|
||||
ctx: ReattachPayloadContext
|
||||
): void {
|
||||
session.applyReattachPayload = async (): Promise<void> => {
|
||||
): {
|
||||
applyReattachPayload: () => Promise<void>
|
||||
fitAfterReattachRestore: () => Promise<void>
|
||||
} {
|
||||
const applyReattachPayload = async (): Promise<void> => {
|
||||
if (!ctx.isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
@@ -280,7 +283,7 @@ export function bindApplyReattachPayload(
|
||||
}
|
||||
}
|
||||
|
||||
session.fitAfterReattachRestore = async (): Promise<void> => {
|
||||
const fitAfterReattachRestore = async (): Promise<void> => {
|
||||
if (!ctx.isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
@@ -312,4 +315,6 @@ export function bindApplyReattachPayload(
|
||||
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
|
||||
}
|
||||
}
|
||||
|
||||
return { applyReattachPayload, fitAfterReattachRestore }
|
||||
}
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe
|
||||
const launchConfig =
|
||||
(useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ??
|
||||
matchingSleepingLaunchConfig
|
||||
// Why: the resume line is typed into this session.pane's live shell, so its quoting must
|
||||
// Why: the resume line is typed into this pane's live shell, so its quoting must
|
||||
// follow the tab's effective Windows shell, not the win32 PowerShell default.
|
||||
const resumeTarget = resolveAgentResumeLaunchTarget({
|
||||
projectRuntime: session.projectRuntime,
|
||||
|
||||
+2
-59
@@ -6,9 +6,8 @@ import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
|
||||
import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate'
|
||||
|
||||
import {
|
||||
type UserInitiatedSshConnectOutcome,
|
||||
isSshSessionExpiredError,
|
||||
sshPromptConnectOutcomeForStatus,
|
||||
waitForUserInitiatedSshConnect,
|
||||
waitForSshConnection
|
||||
} from './ssh-session-connect'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
@@ -84,63 +83,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
if (!alreadyConnected) {
|
||||
// Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete.
|
||||
// Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails.
|
||||
const outcome = await new Promise<UserInitiatedSshConnectOutcome>((resolve) => {
|
||||
// Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected').
|
||||
// Treating the entry-time 'disconnected' as terminal would skip the gate, defeating the passphrase-prompt deferral.
|
||||
let sawNonDisconnected =
|
||||
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
|
||||
'disconnected' &&
|
||||
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
|
||||
undefined
|
||||
let resolvedOutcome: UserInitiatedSshConnectOutcome = 'cancelled'
|
||||
let settled = false
|
||||
const finish = (nextOutcome: UserInitiatedSshConnectOutcome): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
resolvedOutcome = nextOutcome
|
||||
settled = true
|
||||
unsub()
|
||||
const idx = session.waitTeardowns.indexOf(teardown)
|
||||
if (idx !== -1) {
|
||||
session.waitTeardowns.splice(idx, 1)
|
||||
}
|
||||
resolve(resolvedOutcome)
|
||||
}
|
||||
const teardown = (): void => finish('cancelled')
|
||||
// Why: register a teardown so dispose() can unsubscribe+resolve if the session.pane is torn down mid-wait.
|
||||
// Else the zustand subscriber + async IIFE leak: the callback only checks `session.disposed` when it next fires, which may never happen.
|
||||
session.waitTeardowns.push(teardown)
|
||||
const unsub = useAppStore.subscribe((state) => {
|
||||
if (session.disposed) {
|
||||
finish('cancelled')
|
||||
return
|
||||
}
|
||||
const status = state.sshConnectionStates.get(session.connectionId)?.status
|
||||
if (status && status !== 'disconnected') {
|
||||
sawNonDisconnected = true
|
||||
}
|
||||
const nextOutcome = sshPromptConnectOutcomeForStatus(status, sawNonDisconnected)
|
||||
if (nextOutcome) {
|
||||
finish(nextOutcome)
|
||||
}
|
||||
})
|
||||
// Why: re-read state after subscribing to catch a status change that landed between the alreadyConnected check and the subscribe — else we'd wait forever.
|
||||
if (session.disposed) {
|
||||
finish('cancelled')
|
||||
return
|
||||
}
|
||||
const currentStatus = useAppStore
|
||||
.getState()
|
||||
.sshConnectionStates.get(session.connectionId)?.status
|
||||
const currentOutcome = sshPromptConnectOutcomeForStatus(
|
||||
currentStatus,
|
||||
sawNonDisconnected
|
||||
)
|
||||
if (currentOutcome) {
|
||||
finish(currentOutcome)
|
||||
}
|
||||
})
|
||||
const outcome = await waitForUserInitiatedSshConnect(session)
|
||||
if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) {
|
||||
return
|
||||
}
|
||||
|
||||
+5
-5
@@ -32,7 +32,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
)?.ptyId
|
||||
const hasSleepingAgentSession = Boolean(session.getSleepingRecordForPane(storeSnapshot))
|
||||
|
||||
// Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main session.pane waited for split geometry.
|
||||
// Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main pane waited for split geometry.
|
||||
const tabFallbackPtyId =
|
||||
existingPtyId &&
|
||||
!Array.from(session.deps.paneTransportsRef.current.entries()).some(
|
||||
@@ -82,7 +82,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
getEagerPtyBufferHandle(candidateReattachSessionId)
|
||||
)
|
||||
// Why: a still-live locally-spawned PTY (e.g. a background automation agent) keeps an eager buffer until a session.pane adopts it.
|
||||
// Why: a still-live locally-spawned PTY (e.g. a background automation agent) keeps an eager buffer until a pane adopts it.
|
||||
// It must be adopted via attach()+replay — connect({ sessionId }) on its non-session ptyId would spawn a fresh shell and orphan the agent.
|
||||
const eagerLivePtyId =
|
||||
candidateReattachSessionId &&
|
||||
@@ -135,7 +135,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
}
|
||||
} else {
|
||||
// Why: surface synchronous attach failures via session.reportError so the session.pane shows a diagnostic instead of a blank surface.
|
||||
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
|
||||
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
|
||||
try {
|
||||
session.clearPaneMode2031State()
|
||||
@@ -177,7 +177,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
}
|
||||
if (!spawnedPtyId) {
|
||||
// Why: React StrictMode can mount+spawn then immediately remount; if the first mount produced no PTY id,
|
||||
// the remounted session.pane must issue its own spawn instead of attaching to a completed-but-empty promise (a dead surface).
|
||||
// the remounted pane must issue its own spawn instead of attaching to a completed-but-empty promise (a dead surface).
|
||||
if (!isWebTerminalSurfaceTabId(session.deps.tabId)) {
|
||||
console.warn(
|
||||
`Pending PTY spawn for tab ${session.deps.tabId} resolved without a PTY id, retrying fresh spawn`
|
||||
@@ -203,7 +203,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? spawnedPtyId
|
||||
// Why: this reuses a PTY spawned by an earlier mount, so no later spawn event will bind this remounted session.pane's DOM/container.
|
||||
// Why: this reuses a PTY spawned by an earlier mount, so no later spawn event will bind this remounted pane's DOM/container.
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
|
||||
@@ -149,7 +149,7 @@ export function installDirectSshRetryStatus(session: ConnectPanePtySession): voi
|
||||
})
|
||||
: undefined
|
||||
session.shouldOwnAgentStatusInRenderer = session.runtimeEnvironmentId !== null
|
||||
// Why: the host also mirrors agent status for this pane through session.tabs.
|
||||
// Why: the host also mirrors agent status for this pane through tabs.
|
||||
// Claiming here (decided once at transport creation, like the side-effect
|
||||
// authority below) lets the mirror keep this renderer's byte-derived status
|
||||
// instead of overwriting/deleting it on every republication.
|
||||
|
||||
+3
-3
@@ -66,7 +66,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
|
||||
})
|
||||
// Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk.
|
||||
session.transport.resetCrossChunkParserState?.()
|
||||
// Why gated (rc.7.perf loop): on a visible session.pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint.
|
||||
// Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint.
|
||||
if (session.isForegroundRestoreBackpressureContext()) {
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
return
|
||||
@@ -82,7 +82,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
|
||||
// session.buildMainModelSnapshotReplayWrites, which grounds the pen itself, so
|
||||
// nothing is lost by skipping it here.
|
||||
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
|
||||
// Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible session.pane.
|
||||
// Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible pane.
|
||||
const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
if (restoreWasInFlight) {
|
||||
@@ -223,7 +223,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
|
||||
|
||||
session.isLatencySensitiveForegroundOutput = function (data: string): boolean {
|
||||
if (!session.isActiveSplitPane()) {
|
||||
// Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active session.pane.
|
||||
// Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active pane.
|
||||
if (data.includes('\x1b[')) {
|
||||
return false
|
||||
}
|
||||
|
||||
+3
-3
@@ -142,7 +142,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
|
||||
isAlternateScreen?: boolean
|
||||
): string => {
|
||||
// Why a cold restore overrides the agent signal: liveness is read from the
|
||||
// session.pane's status and title, both of which are persisted, so after a cold
|
||||
// pane's status and title, both of which are persisted, so after a cold
|
||||
// restore they describe the process that died. Preserving "its" modes arms
|
||||
// mouse, focus and paste reporting against the fresh shell that replaces it,
|
||||
// which then prints the reports as junk at the prompt (#12101).
|
||||
@@ -152,7 +152,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
|
||||
if (session.shouldPreserveAgentReattachModes()) {
|
||||
return buildPostReplayLiveAgentReattachReset(payload)
|
||||
}
|
||||
// Why: an alt-screen session.pane is a live TUI Orca just does not recognise as an agent, and the
|
||||
// Why: an alt-screen pane is a live TUI Orca just does not recognise as an agent, and the
|
||||
// replay already re-armed its mouse modes — keep them instead of wiping them (#8291).
|
||||
return (isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen)
|
||||
? POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
|
||||
@@ -173,7 +173,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
|
||||
return
|
||||
}
|
||||
// Why: fresh Windows ConPTY output paints at screen coordinates, so
|
||||
// restored session.rows must leave the viewport before the first prompt redraw.
|
||||
// restored rows must leave the viewport before the first prompt redraw.
|
||||
session.writeFreshShellViewportBlanking()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) {
|
||||
// Why: the worktree is being deleted; its PTYs were just killed for the
|
||||
// filesystem teardown. A fresh shell must not spawn into a directory the
|
||||
// removal is about to delete (main fences it anyway), and the session.pane is
|
||||
// removal is about to delete (main fences it anyway), and the pane is
|
||||
// about to unmount — so skip the doomed respawn instead of racing it.
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
typeof spawnedPtyId === 'object' &&
|
||||
spawnedPtyId.agentResumeUnavailable
|
||||
) {
|
||||
// Why: main dropped the resume argv, so this session.pane is a NEW session —
|
||||
// Why: main dropped the resume argv, so this pane is a NEW session —
|
||||
// the plain restored banner would claim the old one came back.
|
||||
session.showSessionRestoredBanner('resume-unavailable')
|
||||
} else if (coldRestoreOverride?.hasSleepingRecord) {
|
||||
@@ -171,7 +171,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
) {
|
||||
// Why: delayed draft/follow-up delivery keys off this launch
|
||||
// registry. If spawn produced no PTY, the launch is no longer a
|
||||
// viable delivery target and must not wait for a future session.pane.
|
||||
// viable delivery target and must not wait for a future pane.
|
||||
session.clearRegisteredStartupLaunchConfig()
|
||||
}
|
||||
if (
|
||||
@@ -183,7 +183,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
session.transport.getPtyId() === resolvedPtyId
|
||||
) {
|
||||
// Why: daemon createOrAttach can turn an apparent fresh spawn into
|
||||
// a reattach; the session.transport skips onPtySpawn there to preserve recency.
|
||||
// a reattach; the transport skips onPtySpawn there to preserve recency.
|
||||
session.bindActivePanePty(resolvedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
@@ -250,8 +250,8 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
|
||||
})
|
||||
})
|
||||
// Why: split panes in the same tab can spawn concurrently. Key by session.pane
|
||||
// as well as tab so a remount cannot attach to a sibling setup session.pane's PTY.
|
||||
// Why: split panes in the same tab can spawn concurrently. Key by pane
|
||||
// as well as tab so a remount cannot attach to a sibling setup pane's PTY.
|
||||
pendingSpawnByPaneKey.set(session.pendingSpawnKey, trackedPromise)
|
||||
return trackedPromise
|
||||
}
|
||||
|
||||
-10
@@ -136,14 +136,4 @@ export function bindHiddenOutputRestoreChunk(session: ConnectPanePtySession): vo
|
||||
meta: { ...meta, rawLength: sliced.length }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an authoritative snapshot to the session.pane's kitty mirror in the order:
|
||||
* unproven reset, replay-semantics scan of the snapshot
|
||||
* bytes (so screen selection lands first), then the owner's proven flags.
|
||||
*
|
||||
* Why the reset only happens when the owner proved something: an old host
|
||||
* omits the field, and downgrading a mirror that is already tracking live
|
||||
* output would lose correct state instead of preserving it.
|
||||
*/
|
||||
}
|
||||
|
||||
+4
-4
@@ -39,9 +39,9 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
|
||||
session.requestHiddenOutputRestoreIfNeeded = function (opts?: {
|
||||
bypassScheduler?: boolean
|
||||
}): boolean {
|
||||
// Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the session.pane and the remount gets a fresh xterm + restore.
|
||||
// Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the pane and the remount gets a fresh xterm + restore.
|
||||
if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) {
|
||||
// Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead session.pane keeps the stale frame forever.
|
||||
// Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead pane keeps the stale frame forever.
|
||||
if (!session.certifiedDeadRestoreRecoveryRequested && !session.disposed) {
|
||||
session.certifiedDeadRestoreRecoveryRequested = true
|
||||
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null
|
||||
@@ -121,7 +121,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
|
||||
if (session.hiddenOutputRestorePtyId === currentPtyId) {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
}
|
||||
// Remote-only path: the session.transport swapped PTYs mid-restore, which is a
|
||||
// Remote-only path: the transport swapped PTYs mid-restore, which is a
|
||||
// stream change, not proof the hidden bytes are unrecoverable.
|
||||
if (
|
||||
!session.rearmRemoteHiddenOutputRestoreInsteadOfWarning(
|
||||
@@ -310,7 +310,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
|
||||
typeof document.removeEventListener === 'function'
|
||||
) {
|
||||
const onDocumentVisibilityChange = (): void => {
|
||||
// Why: document hide/show flips the foreground predicate with no session.pane lifecycle event; re-sync the hidden-delivery gate both ways.
|
||||
// Why: document hide/show flips the foreground predicate with no pane lifecycle event; re-sync the hidden-delivery gate both ways.
|
||||
session.syncHiddenRendererPtyDelivery()
|
||||
if (shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
|
||||
+2
-2
@@ -126,14 +126,14 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
|
||||
snapshot.cols,
|
||||
readProposedTerminalCols(session.pane)
|
||||
)
|
||||
// Why: an imageless success is not proof the session.pane is empty; normal replay clears screen and scrollback.
|
||||
// Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback.
|
||||
const snapshotCarriesNoImage =
|
||||
snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi
|
||||
if (snapshotCarriesNoImage) {
|
||||
// Why still ground: a restore only runs because bytes were dropped, so
|
||||
// the gap's pen outlives a snapshot that cannot repaint over it. The
|
||||
// live-path constant, not the replay baseline — nothing repaints here,
|
||||
// so the session.pane is handed back to whatever is still running.
|
||||
// so the pane is handed back to whatever is still running.
|
||||
session.writeReplayData(RESET_AFTER_BYTE_GAP)
|
||||
} else {
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
|
||||
|
||||
+10
-1
@@ -129,6 +129,15 @@ export function bindHiddenOutputSeqAndSkip(session: ConnectPanePtySession): void
|
||||
|
||||
bindHiddenStartupRendererQueryWrite(session)
|
||||
bindHiddenOutputRestoreChunk(session)
|
||||
/**
|
||||
* Apply an authoritative snapshot to the pane's kitty mirror in the order:
|
||||
* unproven reset, replay-semantics scan of the snapshot
|
||||
* bytes (so screen selection lands first), then the owner's proven flags.
|
||||
*
|
||||
* Why the reset only happens when the owner proved something: an old host
|
||||
* omits the field, and downgrading a mirror that is already tracking live
|
||||
* output would lose correct state instead of preserving it.
|
||||
*/
|
||||
session.applySnapshotKittyKeyboardModes = function (
|
||||
snapshotData: string,
|
||||
snapshot: { kittyKeyboardFlags?: number; snapshotSeq?: number }
|
||||
@@ -152,7 +161,7 @@ export function bindHiddenOutputSeqAndSkip(session: ConnectPanePtySession): void
|
||||
session.kittyKeyboardModes.resetForSnapshot()
|
||||
session.kittyKeyboardModes.scanReplay(snapshotData)
|
||||
session.kittyKeyboardModes.restoreSnapshotFlags(proven)
|
||||
// Why in the same critical section: without the baseline a quiet session.pane
|
||||
// Why in the same critical section: without the baseline a quiet pane
|
||||
// could not publish a coherent snapshot until unrelated output arrived.
|
||||
session.recordRendererOrderedSeq({ seq: snapshot.snapshotSeq })
|
||||
}
|
||||
|
||||
+3
-3
@@ -89,7 +89,7 @@ export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession
|
||||
}
|
||||
|
||||
// ── Hidden-delivery gate sync (Phase 4) ─────────────────────────────
|
||||
// Why: marks this session.pane's PTY hidden in main while no visible view needs
|
||||
// Why: marks this pane's PTY hidden in main while no visible view needs
|
||||
// its bytes; main then drops delivery after model ingestion and reveal
|
||||
// restores from the snapshot. The marked id is tracked locally so PTY
|
||||
// changes (reattach/restart) can never leave a stale id gated.
|
||||
@@ -102,8 +102,8 @@ export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession
|
||||
return Date.now() < session.hiddenOutputRestoreFloodSuppressedUntil
|
||||
}
|
||||
|
||||
// True when a drop/gap signal on a visible session.pane is attributable to this
|
||||
// session.pane's OWN restore backpressure (a restore is replaying right now, or
|
||||
// True when a drop/gap signal on a visible pane is attributable to this
|
||||
// pane's OWN restore backpressure (a restore is replaying right now, or
|
||||
// one was just cut off for outrunning the stream). Such signals must not
|
||||
// re-arm restores — that is the rc.7.perf feedback loop.
|
||||
session.isForegroundRestoreBackpressureContext = function (): boolean {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
|
||||
session.resetHiddenOutputRestoreIfPtyChanged()
|
||||
session.observeLiveMode2031Chunk(data)
|
||||
if (meta?.droppedOutput === true) {
|
||||
// Why gated (rc.7.perf loop): a visible session.pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
|
||||
// Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
|
||||
if (meta?.background !== true && session.isForegroundRestoreBackpressureContext()) {
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
} else {
|
||||
@@ -75,7 +75,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
|
||||
if (codexBackfillNotice) {
|
||||
session.reportError(codexBackfillNotice)
|
||||
}
|
||||
// Why: split panes have visible-but-inactive panes the user watches; throttle only when the session.pane or whole document is hidden.
|
||||
// Why: split panes have visible-but-inactive panes the user watches; throttle only when the pane or whole document is hidden.
|
||||
const foreground =
|
||||
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) && meta?.background !== true
|
||||
// Why: latch the hidden-delivery gate from the byte path too, covering a PTY id that arrives after the initial sync (no-op when current).
|
||||
@@ -94,7 +94,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
|
||||
session.clearRestoredSnapshotBaseline()
|
||||
// fall through with the ORIGINAL data/meta — post-gap bytes are new
|
||||
} else {
|
||||
// Why: capture in-flight BEFORE the mark — on a visible session.pane the mark starts the restore synchronously and must not flag itself.
|
||||
// Why: capture in-flight BEFORE the mark — on a visible pane the mark starts the restore synchronously and must not flag itself.
|
||||
const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
if (restoreWasInFlight) {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void
|
||||
})
|
||||
const orderedSeq =
|
||||
session.rendererOrderedPtyId === ptyId ? session.rendererOrderedSeq : null
|
||||
// Why snapshotFlags and not `flags`: this session.pane may itself have
|
||||
// Why snapshotFlags and not `flags`: this pane may itself have
|
||||
// consumed an old-host snapshot that proved nothing, and its
|
||||
// conservative `0` fallback must not be republished downstream as
|
||||
// a host-proven inactive protocol.
|
||||
|
||||
@@ -41,7 +41,7 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Clear below so a failed parser/replay cannot leave the session.pane generation pending.
|
||||
// Clear below so a failed parser/replay cannot leave the pane generation pending.
|
||||
}
|
||||
await window.api.pty.clearPendingPaneSerializer(session.cacheKey, generation).catch(() => {})
|
||||
}
|
||||
@@ -128,12 +128,12 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
|
||||
useAppStore.getState(),
|
||||
session.deps.worktreeId
|
||||
)
|
||||
// Why: xterm focus reports share this session.transport queue. Bypassing it can
|
||||
// Why: xterm focus reports share this transport queue. Bypassing it can
|
||||
// race CSI I against the draft on ConPTY and expose a literal `[I` prefix.
|
||||
void sendAgentDraftPasteContent(settings, ptyId, session.startupDraftPrompt, async (data) => {
|
||||
const accepted = await writeTerminalPastePtyInput(session.transport, data)
|
||||
if (accepted && !startupDraftInputRecorded) {
|
||||
// Why: this session.transport write bypasses xterm's user-input signal; keep
|
||||
// Why: this transport write bypasses xterm's user-input signal; keep
|
||||
// the composed draft from being discarded by later hibernation.
|
||||
startupDraftInputRecorded = true
|
||||
session.recordTerminalInputForHibernation()
|
||||
@@ -221,7 +221,7 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
|
||||
let sessionRestoredBannerShown: SessionRestoredBannerReason | null = null
|
||||
session.showSessionRestoredBanner = (reason: SessionRestoredBannerReason = 'restored'): void => {
|
||||
// Why: a plain 'restored' banner must not latch out the later 'resume-unavailable'
|
||||
// upgrade — the session.pane would keep claiming a session it never got back.
|
||||
// upgrade — the pane would keep claiming a session it never got back.
|
||||
if (
|
||||
sessionRestoredBannerShown === reason ||
|
||||
sessionRestoredBannerShown === 'resume-unavailable'
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPane } from '../pty-connection-test-pane-fixtures'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from '../pty-connection-test-environment'
|
||||
import { createReattachPayloadHandlers } from './apply-reattach-payload'
|
||||
import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
|
||||
import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
import type { ReattachPayloadSession } from './reattach-payload-session'
|
||||
|
||||
function createSession(overrides: Record<string, unknown> = {}): ReattachPayloadSession {
|
||||
return {
|
||||
pane: createPane(1),
|
||||
rememberReattachPayloadAgentSignal: vi.fn(),
|
||||
writeReplayData: vi.fn(),
|
||||
reattachReplayResetSequence: vi.fn(() => '<reset>'),
|
||||
sendFocusedReattachFocusInAfterReplay: vi.fn(),
|
||||
kittyKeyboardModes: {
|
||||
hasProvenBaseline: true,
|
||||
reset: vi.fn(),
|
||||
resetForSnapshot: vi.fn(),
|
||||
scanReplay: vi.fn()
|
||||
},
|
||||
...overrides
|
||||
} as unknown as ReattachPayloadSession
|
||||
}
|
||||
|
||||
function createContext(replay: string, attemptGeneration: number): ReattachPayloadContext {
|
||||
return {
|
||||
isCurrentReattachPayload: () => true,
|
||||
connectResult: { id: 'pty-1', replay },
|
||||
ptyId: 'pty-1',
|
||||
attemptGeneration,
|
||||
prefetchedParkModelSnapshot: null,
|
||||
revealFollowsTerminalPark: false,
|
||||
fetchSshMainModelReattachSnapshot: async () => null,
|
||||
hasStructuralReplay: true,
|
||||
coldRestoreStartup: undefined,
|
||||
reattachPayloadApplied: false
|
||||
}
|
||||
}
|
||||
|
||||
describe('reattach payload context', () => {
|
||||
beforeEach(() => installTerminalTestGlobals())
|
||||
|
||||
afterEach(async () => restoreTerminalTestGlobals())
|
||||
|
||||
it('keeps overlapping payload handlers bound to their own attempt', async () => {
|
||||
const session = createSession()
|
||||
const attemptA = createReattachPayloadHandlers(session, createContext('PAYLOAD-A', 1))
|
||||
const attemptB = createReattachPayloadHandlers(session, createContext('PAYLOAD-B', 2))
|
||||
|
||||
await attemptB.applyReattachPayload()
|
||||
await attemptA.applyReattachPayload()
|
||||
|
||||
expect(session.rememberReattachPayloadAgentSignal.mock.calls).toEqual([
|
||||
['PAYLOAD-B', { fullScreenReplay: true }],
|
||||
['PAYLOAD-A', { fullScreenReplay: true }]
|
||||
])
|
||||
expect(session.sendFocusedReattachFocusInAfterReplay.mock.calls).toEqual([
|
||||
['pty-1', 2],
|
||||
['pty-1', 1]
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the connect startup plan without rebuilding or scheduling paste delivery', async () => {
|
||||
const startup = {
|
||||
command: 'codex resume provider-session',
|
||||
agent: 'codex',
|
||||
resumeProviderSession: { key: 'session_id', id: 'provider-session' },
|
||||
launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} },
|
||||
launchToken: 'transport-launch-token',
|
||||
useLiveEntry: false,
|
||||
hasSleepingRecord: false,
|
||||
sleepingRecordEntry: null
|
||||
} as ColdRestoreAgentResumeStartup
|
||||
const buildColdRestoreAgentResumeStartup = vi.fn()
|
||||
const applyColdRestoreAgentResumeStartup = vi.fn(() => true)
|
||||
const schedulePendingStartupCommandDelivery = vi.fn()
|
||||
const session = createSession({
|
||||
buildColdRestoreAgentResumeStartup,
|
||||
applyColdRestoreAgentResumeStartup,
|
||||
schedulePendingStartupCommandDelivery,
|
||||
showSessionRestoredBanner: vi.fn(),
|
||||
clearSleepingRecordAfterColdRestoreSpawn: vi.fn(),
|
||||
consumeRestoredViewportBlankingMarker: vi.fn(),
|
||||
writeFreshShellViewportBlanking: vi.fn()
|
||||
})
|
||||
const context: ReattachPayloadContext = {
|
||||
...createContext('', 1),
|
||||
connectResult: {
|
||||
id: 'pty-1',
|
||||
coldRestore: { scrollback: 'restored scrollback', cwd: '/workspace' }
|
||||
},
|
||||
coldRestoreStartup: startup
|
||||
}
|
||||
|
||||
await createReattachPayloadHandlers(session, context).applyReattachPayload()
|
||||
|
||||
expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(startup)
|
||||
expect(buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled()
|
||||
expect(schedulePendingStartupCommandDelivery).not.toHaveBeenCalled()
|
||||
expect(context.reattachPayloadApplied).toBe(true)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -11,6 +11,6 @@ export type ReattachPayloadContext = {
|
||||
revealFollowsTerminalPark: boolean
|
||||
fetchSshMainModelReattachSnapshot: () => Promise<PtyBufferSnapshot | null>
|
||||
hasStructuralReplay: boolean
|
||||
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null
|
||||
coldRestoreStartup: ColdRestoreAgentResumeStartup | null | undefined
|
||||
reattachPayloadApplied: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export type ReattachPayloadSession = Pick<
|
||||
ConnectPanePtySession,
|
||||
| 'applyColdRestoreAgentResumeStartup'
|
||||
| 'applySnapshotKittyKeyboardModes'
|
||||
| 'buildColdRestoreAgentResumeStartup'
|
||||
| 'clearSleepingRecordAfterColdRestoreSpawn'
|
||||
| 'consumeRestoredViewportBlankingMarker'
|
||||
| 'createReattachGridPush'
|
||||
| 'isPaneOnAlternateScreen'
|
||||
| 'kittyKeyboardModes'
|
||||
| 'pane'
|
||||
| 'pendingReattachFit'
|
||||
| 'reattachReplayResetSequence'
|
||||
| 'recordRendererOrderedSeq'
|
||||
| 'rememberReattachPayloadAgentSignal'
|
||||
| 'schedulePendingStartupCommandDelivery'
|
||||
| 'sendFocusedReattachFocusInAfterReplay'
|
||||
| 'setRestoredSnapshotBaseline'
|
||||
| 'shouldPreserveAgentReattachModes'
|
||||
| 'showSessionRestoredBanner'
|
||||
| 'suppressStructuralReplayPtyResize'
|
||||
| 'transport'
|
||||
| 'writeFreshShellViewportBlanking'
|
||||
| 'writeReplayData'
|
||||
>
|
||||
+40
-8
@@ -19,9 +19,38 @@ import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
import { bindApplyReattachPayload } from './apply-reattach-payload'
|
||||
import { createReattachPayloadHandlers } from './apply-reattach-payload'
|
||||
import type { ReattachPayloadSession } from './reattach-payload-session'
|
||||
|
||||
export function bindHandleReattachResult(session: ConnectPanePtySession): void {
|
||||
type ReattachResultSession = ReattachPayloadSession &
|
||||
Pick<
|
||||
ConnectPanePtySession,
|
||||
| 'agentCompletionCoordinator'
|
||||
| 'authoritativeReattachGeneration'
|
||||
| 'capturedDirectSshRetryPtyAccepted'
|
||||
| 'deps'
|
||||
| 'directSshRetryAttempt'
|
||||
| 'disposed'
|
||||
| 'getSshMainModelSnapshotProbe'
|
||||
| 'handleReattachResult'
|
||||
| 'mountFollowsTerminalPark'
|
||||
| 'registerEffectiveLaunchConfig'
|
||||
| 'registerPaneSerializerFor'
|
||||
| 'registerSideEffectFactConsumerForPty'
|
||||
| 'rejectObsoleteDirectSshReattach'
|
||||
| 'reportPanePtyVisibility'
|
||||
| 'sampleVisiblePaneForegroundAgent'
|
||||
| 'scheduleReattachIdleAgentCursorReset'
|
||||
| 'serializeHiddenOutputSnapshot'
|
||||
| 'setPanePtyFitBinding'
|
||||
| 'startFreshColdRestoreAgentResume'
|
||||
| 'structuralReplayCoordinator'
|
||||
| 'syncHiddenRendererPtyDelivery'
|
||||
| 'transportStreamGeneration'
|
||||
>
|
||||
|
||||
export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): void {
|
||||
const session = sessionBag as unknown as ReattachResultSession
|
||||
session.handleReattachResult = async (
|
||||
result: PtyConnectResult | string | void,
|
||||
staleSessionId?: string | null,
|
||||
@@ -39,7 +68,7 @@ export function bindHandleReattachResult(session: ConnectPanePtySession): void {
|
||||
result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null
|
||||
|
||||
if (connectResult?.exitedBeforeAttach) {
|
||||
// Why: the session.transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
|
||||
// Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -206,15 +235,18 @@ export function bindHandleReattachResult(session: ConnectPanePtySession): void {
|
||||
coldRestoreStartup,
|
||||
reattachPayloadApplied: !hasStructuralReplay && prefetchedParkModelSnapshot === null
|
||||
}
|
||||
bindApplyReattachPayload(session, reattachPayload)
|
||||
const { applyReattachPayload, fitAfterReattachRestore } = createReattachPayloadHandlers(
|
||||
session,
|
||||
reattachPayload
|
||||
)
|
||||
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
|
||||
await session.structuralReplayCoordinator.run(session.applyReattachPayload, {
|
||||
await session.structuralReplayCoordinator.run(applyReattachPayload, {
|
||||
shouldRestore: isCurrentReattachPayload,
|
||||
afterRestore: session.fitAfterReattachRestore
|
||||
afterRestore: fitAfterReattachRestore
|
||||
})
|
||||
} else {
|
||||
await session.applyReattachPayload()
|
||||
await session.fitAfterReattachRestore()
|
||||
await applyReattachPayload()
|
||||
await fitAfterReattachRestore()
|
||||
}
|
||||
if (!isCurrentReattachPayload() || !reattachPayload.reattachPayloadApplied) {
|
||||
return false
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { SSH_SESSION_EXPIRED_ERROR } from './pty-connect-limits'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
// Why: when multiple panes/tabs need the same deferred SSH connection,
|
||||
// the first one calls ssh.connect() and subsequent ones must wait for it
|
||||
// rather than returning early (which would leave them disconnected). This
|
||||
// helper either connects or waits for an in-flight connect to finish.
|
||||
export type SshConnectResult = { connected: true } | { connected: false; error: string }
|
||||
export type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed'
|
||||
type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed'
|
||||
|
||||
const sshConnectPromises = new Map<string, Promise<SshConnectResult>>()
|
||||
|
||||
@@ -14,7 +15,7 @@ export function isSshSessionExpiredError(err: unknown): boolean {
|
||||
return (err instanceof Error ? err.message : String(err)).includes(SSH_SESSION_EXPIRED_ERROR)
|
||||
}
|
||||
|
||||
export function sshPromptConnectOutcomeForStatus(
|
||||
function sshPromptConnectOutcomeForStatus(
|
||||
status: string | undefined,
|
||||
sawNonDisconnected: boolean
|
||||
): UserInitiatedSshConnectOutcome | null {
|
||||
@@ -32,6 +33,60 @@ export function sshPromptConnectOutcomeForStatus(
|
||||
return null
|
||||
}
|
||||
|
||||
export function waitForUserInitiatedSshConnect(
|
||||
session: ConnectPanePtySession
|
||||
): Promise<UserInitiatedSshConnectOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
// Entry-time disconnected means authentication has not started; it only cancels after another status was observed.
|
||||
let sawNonDisconnected = !['disconnected', undefined].includes(
|
||||
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status
|
||||
)
|
||||
let settled = false
|
||||
const finish = (outcome: UserInitiatedSshConnectOutcome): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
unsubscribe()
|
||||
const index = session.waitTeardowns.indexOf(teardown)
|
||||
if (index !== -1) {
|
||||
session.waitTeardowns.splice(index, 1)
|
||||
}
|
||||
resolve(outcome)
|
||||
}
|
||||
const teardown = (): void => finish('cancelled')
|
||||
// Disposal must resolve the wait even if the SSH store never emits again.
|
||||
session.waitTeardowns.push(teardown)
|
||||
const readOutcome = (status: string | undefined): UserInitiatedSshConnectOutcome | null => {
|
||||
if (status && status !== 'disconnected') {
|
||||
sawNonDisconnected = true
|
||||
}
|
||||
return sshPromptConnectOutcomeForStatus(status, sawNonDisconnected)
|
||||
}
|
||||
const unsubscribe = useAppStore.subscribe((state) => {
|
||||
if (session.disposed) {
|
||||
finish('cancelled')
|
||||
return
|
||||
}
|
||||
const outcome = readOutcome(state.sshConnectionStates.get(session.connectionId)?.status)
|
||||
if (outcome) {
|
||||
finish(outcome)
|
||||
}
|
||||
})
|
||||
if (session.disposed) {
|
||||
finish('cancelled')
|
||||
return
|
||||
}
|
||||
// Catch a status change that landed between the caller's check and this subscription.
|
||||
const currentOutcome = readOutcome(
|
||||
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status
|
||||
)
|
||||
if (currentOutcome) {
|
||||
finish(currentOutcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForSshConnection(connectionId: string): Promise<SshConnectResult> {
|
||||
const state = useAppStore.getState().sshConnectionStates.get(connectionId)
|
||||
if (state?.status === 'connected') {
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi
|
||||
},
|
||||
onRecoveryStateChange: (state: PtyTransportRecoveryState): void => {
|
||||
if (isCurrent()) {
|
||||
// Why: cached pixels remain visible while detached; expose session.transport truth for diagnostics and recovery UI.
|
||||
// Why: cached pixels remain visible while detached; expose transport truth for diagnostics and recovery UI.
|
||||
session.pane.container.dataset.ptyRecoveryState = state.phase
|
||||
session.deps.onPtyRecoveryStateRef?.current?.(session.pane.id, state)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi
|
||||
session.hiddenOutputRestoreRemoteAbandonCycles = 0
|
||||
session.hiddenOutputSnapshotScrollRestore = null
|
||||
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
|
||||
// can reuse the session.pane object for a different session before visibility.
|
||||
// can reuse the pane object for a different session before visibility.
|
||||
session.hiddenOutputRestorePtyId = null
|
||||
// One recovery re-kick per xterm instance. Generation-aware cooldown and
|
||||
// window-cap retries keep a fresh-but-wedged replacement from fossilizing.
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void
|
||||
foreground: boolean,
|
||||
opts?: { hiddenStartupRendererQuery?: boolean }
|
||||
): void {
|
||||
// Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the session.pane's protocol negotiation.
|
||||
// Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the pane's protocol negotiation.
|
||||
session.kittyKeyboardModes.scan(data)
|
||||
if (foreground) {
|
||||
session.resetHiddenOutputRestoreIfPtyChanged()
|
||||
|
||||
Reference in New Issue
Block a user