Preserve agent chats and drafts through reconnects (#9242)

* fix(chat): preserve drafts through reconnects

* fix(terminal): bound stale handle reconnect polling

* refactor(chat): extract native view props

* fix(chat): harden reconnect lifecycle

* fix(terminal): replace stale reconnect identities atomically

* fix(terminal): recover mirrors after reconnect polling

* docs(reliability): record merged reconnect coverage

* fix(terminal): preserve reconnect lifecycle state

* fix(terminal): keep reconnect polling truly bounded
This commit is contained in:
Brennan Benson
2026-07-18 20:31:14 -07:00
committed by GitHub
parent ae1c943da9
commit 02ff7c7465
32 changed files with 1497 additions and 191 deletions
+52 -10
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-13",
"updatedAt": "2026-07-17",
"policy": {
"maturityLevels": [
"experimental",
@@ -2286,9 +2286,10 @@
"macos"
],
"coveredProviders": [
"ssh"
"ssh",
"remote-runtime"
],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, remote or unresolved hosts cannot enter local-daemon cold activation deferral, and stale cleanup cannot unregister a replacement runtime terminal. A macOS Electron journey connects to a real Linux SSH relay in Docker, persists six terminals, reloads the renderer, and verifies all six original relay PTYs remount eagerly and still execute remote input. Linux and Windows desktop clients, WSL, and remote-runtime restore remain gaps.",
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, remote or unresolved hosts cannot enter local-daemon cold activation deferral, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime web mirror replaces a stale handle without retiring its pane or accumulating obsolete PTY identities. Bounded polling falls back to accepted host snapshots without a global store subscription, and concurrent panes share each in-flight inventory request. A macOS Electron journey connects to a real Linux SSH relay in Docker, persists six terminals, reloads the renderer, and verifies all six original relay PTYs remount eagerly and still execute remote input. Linux and Windows desktop clients, WSL, and live remote-runtime restore remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
@@ -2296,10 +2297,10 @@
"https://github.com/stablyai/orca/pull/7009",
"https://github.com/stablyai/orca/pull/8597"
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Snapshot-backed cold activation deferral is local-daemon-only; every restored remote terminal must mount a manager eagerly and preserve its provider PTY identity.",
"oracle": "Deterministic tests assert startup publishes ssh.connect's authoritative state before terminal reconnect, only an explicit local execution host can defer cold activation, host ownership changes clear old restrictions, and replacement runtime registrations survive stale cleanup. The live Docker SSH journey commits six terminal records to the relay, reloads Electron, requires the exact six manager ids with no parked tabs, compares all restored PTY ids to their pre-reload identities, and sends a marker through the restored terminal to a proof file on the Linux host.",
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Snapshot-backed cold activation deferral is local-daemon-only; every restored remote terminal must mount a manager eagerly and preserve its provider PTY identity. A stale remote-runtime mirror handle must keep its pane mounted until a replacement or explicit terminal-removal fact arrives, and each replacement must atomically supersede rather than accumulate PTY identity.",
"oracle": "Deterministic tests assert startup publishes ssh.connect's authoritative state before terminal reconnect, only an explicit local execution host can defer cold activation, host ownership changes clear old restrictions, replacement runtime registrations survive stale cleanup, stale mirror polling is bounded, and replacement handles resubscribe without exit/disconnect callbacks or stale PTY index growth. Count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind. The live Docker SSH journey commits six terminal records to the relay, reloads Electron, requires the exact six manager ids with no parked tabs, compares all restored PTY ids to their pre-reload identities, and sends a marker through the restored terminal to a proof file on the Linux host.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
@@ -2311,6 +2312,10 @@
"src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"src/renderer/src/runtime/web-session-terminal-handle-events.test.ts",
"src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
"tests/e2e/ssh-cold-activation-restore.spec.ts"
],
@@ -2328,6 +2333,34 @@
"SSH, remote-runtime, and unresolved owners remain eager"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"assertions": [
"a stale web-mirror handle polls until a different ready handle is published without resubscribing the stale handle",
"replacement does not emit pane exit or disconnect callbacks and explicit terminal exit still retires the mirror",
"replacement polling and each in-flight request share a 15-second deadline, then accepted snapshots own recovery without input re-arming polling"
]
},
{
"file": "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"assertions": [
"concurrent panes share one in-flight inventory request within a runtime/worktree and do not share across ownership boundaries"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts",
"assertions": [
"accepted host snapshot listeners are scoped by runtime, worktree, and pane and distinguish pending handles from removed surfaces",
"listeners are released after the waiting transport settles"
]
},
{
"file": "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"assertions": [
"repeated handle rotations retain exactly one live PTY identity and update the tab fallback atomically",
"snapshot-first replacement still migrates stale PTY-indexed state"
]
},
{
"file": "tests/e2e/ssh-cold-activation-restore.spec.ts",
"assertions": [
@@ -2346,6 +2379,15 @@
"result": "passed",
"durationSeconds": 128,
"summary": "Six focused files and 475 tests passed; the local cold-activation journey passed; the Docker/Linux SSH journey restored six of six original relay PTYs, mounted six of six managers without parking, and executed remote input through the restored terminal."
},
{
"date": "2026-07-17",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"result": "passed",
"durationSeconds": 15,
"summary": "Ten provider-contract files and 566 tests passed, including a shared 15-second polling/RPC deadline, no input-triggered re-polling after the bound, post-timeout snapshot recovery, listener cleanup, in-flight inventory deduplication, and replacement-state migration."
}
],
"runtimeBudget": {
@@ -2358,11 +2400,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests assert SSH attach uses relay pty.attach for saved sessions, expired relay attach does not fresh-spawn in the provider, a failed SSH listProcesses observation does not clear previously learned ownership, a rejected SSH hasPty probe resolves as unknown liveness, deferred SSH passphrase cancellation does not auto-reconnect, deferred attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without clearing pane/tab bindings or consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. Needs WSL, remote-runtime mirror polling, live SSH, and saved intentional-break artifacts before promotion."
"evidence": "Tests assert SSH attach uses relay pty.attach for saved sessions, expired relay attach does not fresh-spawn in the provider, a failed SSH listProcesses observation does not clear previously learned ownership, a rejected SSH hasPty probe resolves as unknown liveness, deferred SSH passphrase cancellation does not auto-reconnect, deferred attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without clearing pane/tab bindings or consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn, and remote-runtime stale handles rotate in place without stale resubscription, pane retirement, or PTY index growth. Needs WSL, live remote-runtime/SSH, and saved intentional-break artifacts before promotion."
},
"performanceBudget": {
"required": true,
"evidence": "Host eligibility is resolved from existing store metadata during activation and adds no provider listing or polling. The local journey preserves cold deferral, while SSH eagerly mounts six managers as required; no work is added to typing or terminal-output hot paths."
"evidence": "Host eligibility is resolved from existing store metadata during activation. Remote-runtime resubscribe polling runs only after a transport close, reserves replacement-only matching for typed stale-handle evidence, backs off from 150 ms to 1 s, caps each request to the remaining 15-second reconnect deadline, and shares each in-flight inventory request across panes in the same runtime/worktree. Accepted host snapshots use an identity-scoped listener only while recovery is pending; after the bound there is no timer or global store subscriber, and stale input cannot re-arm polling. The listener is released on rebind, removal, detach, or destroy, and PTY state is atomically replaced instead of growing. The local journey preserves cold deferral, while SSH eagerly mounts six managers as required; no work is added to typing or terminal-output hot paths."
},
"promotionCriteria": [
"Use deterministic fake providers for failure and unknown-liveness cases.",
@@ -2372,8 +2414,8 @@
"knownGaps": [
"Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.",
"The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.",
"WSL and remote-runtime mirror polling contracts are not wired yet.",
"Linux and Windows desktop-client journeys are not yet collected."
"WSL restore remains inferred rather than directly covered.",
"Linux and Windows desktop-client journeys and a live remote-runtime reconnect soak are not yet collected."
],
"demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only."
},
@@ -30,7 +30,8 @@ const mocks = vi.hoisted(() => ({
sendNativeChatMessage: vi.fn(),
sendNativeChatMessageVerified: vi.fn(),
trackPendingSend: vi.fn(),
setDraft: vi.fn()
setDraft: vi.fn(),
draftScopeKeys: [] as string[]
}))
vi.mock('../../store', () => {
@@ -69,7 +70,10 @@ vi.mock('@/lib/native-chat-telemetry', () => ({
emitNativeChatMessageSent: vi.fn()
}))
vi.mock('./use-native-chat-draft', () => ({
useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft })
useNativeChatDraft: (scopeKey: string) => {
mocks.draftScopeKeys.push(scopeKey)
return { draft: 'hello', setDraft: mocks.setDraft }
}
}))
vi.mock('./native-chat-draft-cache', () => ({
readNativeChatDraftCache: () => ''
@@ -122,6 +126,7 @@ describe('NativeChatComposer', () => {
clearNativeChatSessionOptionCacheForTests()
mocks.fieldProps = null
mocks.modelSwitchOutcome = 'applied'
mocks.draftScopeKeys.length = 0
mocks.confirmationObserver = null
mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => {
const observer = {
@@ -154,6 +159,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
isWorking
@@ -175,6 +181,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
onOptimisticSend={onOptimisticSend}
@@ -187,6 +194,36 @@ describe('NativeChatComposer', () => {
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
})
it('keeps the draft scope anchored to the pane while the PTY reconnects', () => {
const view = render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-before"
agent="codex"
/>
)
view.rerender(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId={null}
agent="codex"
/>
)
view.rerender(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-after"
agent="codex"
/>
)
expect(new Set(mocks.draftScopeKeys)).toEqual(new Set(['tab-1:leaf-1']))
})
it('shows the model already selected in the Claude TUI when chat opens', async () => {
mocks.getMainBufferSnapshot.mockResolvedValue({
data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing',
@@ -196,6 +233,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
readTerminalScreen={() => null}
@@ -231,6 +269,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
readTerminalScreen={() =>
@@ -266,6 +305,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
onSlashCommand={onSlashCommand}
@@ -300,6 +340,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
onSwitchToTerminal={onSwitchToTerminal}
@@ -340,6 +381,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
onSwitchToTerminal={onSwitchToTerminal}
@@ -365,6 +407,7 @@ describe('NativeChatComposer', () => {
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
onSwitchToTerminal={onSwitchToTerminal}
@@ -1,6 +1,5 @@
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useAppStore } from '../../store'
import type { AgentType } from '../../../../shared/agent-status-types'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import {
@@ -40,6 +39,15 @@ import { useNativeChatSessionOptions } from './use-native-chat-session-options'
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
import { useNativeChatDictationActions } from './use-native-chat-dictation-actions'
import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command'
import type {
NativeChatComposerHandle,
NativeChatComposerProps
} from './native-chat-composer-types'
export type {
NativeChatComposerHandle,
NativeChatComposerProps
} from './native-chat-composer-types'
// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a
// PTY (matching how xterm forwards Escape). The richer interrupt-intent
@@ -47,54 +55,6 @@ import { useNativeChatSessionOptionCommand } from './use-native-chat-session-opt
// observers, so writing ESC through the same send path feeds that machinery.
const ESC = '\x1b'
export type NativeChatComposerProps = {
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
terminalTabId: string
/** Specific split-pane PTY this chat view owns. */
targetPtyId: string | null
agent: AgentType
/**
* Mobile presence-lock seam (R8): when a mobile client holds the pty, desktop
* sends must be guarded rather than silently dropped. U9 wires the real lock
* state in; until then this defaults to `true` (sendable) and the composer
* already renders the guarded/disabled affordance when it is `false`.
*/
canSend?: boolean
/** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */
isWorking?: boolean
/** Interrupt the hosted agent, usually by sending ESC into the PTY. */
onStop?: () => void
/** Optional optimistic-send hook: called with the sent text so the view can
* render a "queued" echo until the real transcript turn lands (mobile parity). */
onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined
/** Remove an optimistic echo when its delayed submit is canceled. */
onOptimisticSendCanceled?: (pendingId: string) => void
/** Called with a dispatched slash command (e.g. `/clear`) so the view can show
* a small "Ran /clear" system line — slash commands aren't chat turns and
* otherwise leave no visible trace that anything happened. */
onSlashCommand?: (command: string) => void
/** Picker-only agent commands continue in the hosted TUI after dispatch. */
onSwitchToTerminal?: () => void
/** Reads the hosted TUI's current rendered screen when chat is entered. */
readTerminalScreen?: () => string | null
}
export type NativeChatComposerHandle = {
focus: () => boolean
insertTypedText: (text: string) => boolean
/** Handle a paste event captured at the pane root (the OS frequently
* retargets the paste off the focused textarea, so its own onPaste can't be
* relied on). An image is intercepted and attached; text falls through. */
handlePasteEvent: (event: {
clipboardData: DataTransfer | null
preventDefault: () => void
defaultPrevented: boolean
}) => void
/** Paste the clipboard into the composer with no event in hand (menu paste):
* an image becomes an attachment, otherwise text is inserted at the caret. */
pasteFromClipboard: () => void
}
/**
* Rich native input for the chat view. Sends prompts into the running agent
* through the same verified runtime path as typed input (KTD4), so the agent
@@ -107,6 +67,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
function NativeChatComposer(
{
terminalTabId,
paneKey,
targetPtyId,
agent,
canSend = true,
@@ -121,8 +82,10 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
ref
): React.JSX.Element {
// Scope key shared with image attachments so an unsent draft + its attached
// images survive the composer unmounting on a TUI/GUI toggle.
const draftScopeKey = targetPtyId ?? terminalTabId
// images survive both TUI/GUI toggles and PTY replacement on reconnect.
// Why: local, SSH, and runtime reconnects can replace or temporarily clear
// the PTY id. Pane identity is the stable ownership key for unsent input.
const draftScopeKey = paneKey
const { draft, setDraft } = useNativeChatDraft(draftScopeKey)
const [caret, setCaret] = useState(draft.length)
const [history, setHistory] = useState<HistoryState>(EMPTY_HISTORY)
@@ -180,7 +143,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } =
useNativeChatComposerAttachments({
attachmentScopeKey: targetPtyId ?? terminalTabId,
attachmentScopeKey: paneKey,
caret,
resolveTarget,
textareaRef,
@@ -1,4 +1,4 @@
import type * as React from 'react'
import { useEffect, useRef, type JSX, type ReactNode } from 'react'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import {
resolveNativeChatSession,
@@ -7,7 +7,7 @@ import {
} from './native-chat-pane-resolution'
export type NativeChatSessionGateProps = NativeChatPaneResolutionInput & {
children: (resolution: NativeChatPaneResolution) => React.ReactNode
children: (resolution: NativeChatPaneResolution) => ReactNode
}
/** Keeps NativeChatView's agent/session resolution separate from the heavy
@@ -15,8 +15,38 @@ export type NativeChatSessionGateProps = NativeChatPaneResolutionInput & {
export function NativeChatSessionGate({
children,
...input
}: NativeChatSessionGateProps): React.JSX.Element {
const resolution = resolveNativeChatSession(input)
}: NativeChatSessionGateProps): JSX.Element {
const lastResolutionRef = useRef<NativeChatPaneResolution | null>(null)
const currentResolution = resolveNativeChatSession(input)
const previousResolution =
lastResolutionRef.current?.paneKey === input.paneKey ? lastResolutionRef.current : null
const resolution = (() => {
if (!currentResolution) {
return previousResolution
}
if (
previousResolution?.agent === currentResolution.agent &&
previousResolution.sessionId &&
!currentResolution.sessionId
) {
// Why: reconnect snapshots can retain a pane/agent fallback while briefly
// omitting provider-session metadata. Keep the conversation generation
// stable so transcript IO and the composer do not reset mid-reconnect.
return {
...currentResolution,
sessionId: previousResolution.sessionId,
transcriptPath: previousResolution.transcriptPath
}
}
return currentResolution
})()
useEffect(() => {
if (resolution) {
// Why: hook and title evidence are transport-fed and can vanish between a
// disconnect and replay. Commit the last rendered conversation identity.
lastResolutionRef.current = resolution
}
}, [resolution])
if (!resolution) {
return <NativeChatEmptyState kind="not-agent" />
}
@@ -3,10 +3,12 @@
import '@testing-library/jest-dom/vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type * as React from 'react'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { NativeChatSessionGate } from './NativeChatSessionGate'
import { useNativeChatDraft } from './use-native-chat-draft'
import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache'
function entry(overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>) {
return {
@@ -33,9 +35,24 @@ function renderResolution(
)
}
function DraftProbe({ paneKey, sessionId }: { paneKey: string; sessionId: string | null }) {
const { draft, setDraft } = useNativeChatDraft(paneKey)
return (
<label>
Session {sessionId ?? 'none'}
<input
aria-label="Message draft"
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
/>
</label>
)
}
describe('NativeChatSessionGate', () => {
afterEach(() => {
cleanup()
clearNativeChatDraftCacheForTests()
})
it.each(['codex', 'claude'] as const)(
@@ -72,6 +89,54 @@ describe('NativeChatSessionGate', () => {
)
})
it('preserves the open composer, session, and draft through disconnect and reconnect', () => {
const paneKey = 'tab-1:leaf-1'
const connectedEntry = entry({
paneKey,
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-session' }
})
const renderGate = (
agentStatusEntry?: AgentStatusEntry,
launchAgent: 'codex' | null = null
) => (
<NativeChatSessionGate
paneKey={paneKey}
launchAgent={launchAgent}
resolvedAgent={null}
agentStatusEntry={agentStatusEntry}
ptyId={agentStatusEntry ? 'pty-connected' : null}
>
{(resolution) => (
<DraftProbe paneKey={resolution.paneKey} sessionId={resolution.sessionId} />
)}
</NativeChatSessionGate>
)
const view = render(renderGate(connectedEntry))
const composer = screen.getByRole('textbox', { name: 'Message draft' })
fireEvent.change(composer, { target: { value: 'keep this unsent message' } })
view.rerender(renderGate(undefined, 'codex'))
expect(screen.getByText('Session codex-session')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Message draft' })).toHaveValue(
'keep this unsent message'
)
view.rerender(renderGate())
expect(screen.getByText('Session codex-session')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Message draft' })).toHaveValue(
'keep this unsent message'
)
view.rerender(renderGate(connectedEntry))
expect(screen.getByText('Session codex-session')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Message draft' })).toHaveValue(
'keep this unsent message'
)
})
it('does not open native chat from an unsupported title fallback', () => {
renderResolution({
paneKey: 'tab-1:leaf-1',
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import type { TuiAgent } from '../../../../shared/types'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
import { useNativeChatLiveSession } from './use-native-chat-live-session'
import { selectNativeChatViewState } from './native-chat-view-state'
@@ -52,24 +51,9 @@ import { resolveNativeChatFileLinkContext } from './native-chat-file-link'
import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner'
import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
import type { NativeChatViewProps } from './native-chat-view-types'
export type NativeChatViewProps = {
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
terminalTabId: string
/** Specific split leaf this chat surface replaces. */
paneKey?: string
/** PTY bound to `paneKey`, used for composer and interactive-card sends. */
targetPtyId?: string | null
/** Launch-time agent hint from the TerminalTab, when Orca started one. */
launchAgent?: TuiAgent | null
/** Trusted title/foreground fallback for manually-started agents. */
resolvedAgent?: TuiAgent | null
/** Return this pane to the hosted terminal surface. */
onSwitchToTerminal?: () => void
/** Current xterm screen reader used to recover agent-reported session state. */
readTerminalScreen?: () => string | null
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
}
export type { NativeChatViewProps } from './native-chat-view-types'
/** Resolves an agent terminal into its native conversation and composer UI. */
export default function NativeChatView({
@@ -447,6 +431,7 @@ function NativeChatResolvedView({
<NativeChatComposer
ref={composerRef}
terminalTabId={terminalTabId}
paneKey={paneKey}
targetPtyId={targetPtyId}
agent={agent}
canSend={canSend}
@@ -1,5 +1,5 @@
// Shared LRU bound for the native-chat composer's per-scope caches (draft text
// and image attachments), both keyed by `targetPtyId ?? terminalTabId`. The
// and image attachments), both keyed by stable pane identity. The
// caches exist so an in-progress message survives the composer unmounting on a
// TUI/GUI toggle, but a scope key for a permanently-removed pane is never
// revisited, so without a bound its unsent entry would linger for the renderer's
@@ -0,0 +1,40 @@
import type { AgentType } from '../../../../shared/agent-status-types'
export type NativeChatComposerProps = {
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
terminalTabId: string
/** Stable split-leaf identity; unlike a PTY id, this survives reconnects. */
paneKey: string
/** Specific split-pane PTY this chat view owns. */
targetPtyId: string | null
agent: AgentType
/** Guard desktop sends while a mobile client owns the terminal input lease. */
canSend?: boolean
/** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */
isWorking?: boolean
/** Interrupt the hosted agent, usually by sending ESC into the PTY. */
onStop?: () => void
/** Render an optimistic echo until the real transcript turn lands. */
onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined
/** Remove an optimistic echo when its delayed submit is canceled. */
onOptimisticSendCanceled?: (pendingId: string) => void
/** Record a dispatched slash command that does not create a chat turn. */
onSlashCommand?: (command: string) => void
/** Picker-only agent commands continue in the hosted TUI after dispatch. */
onSwitchToTerminal?: () => void
/** Reads the hosted TUI's current rendered screen when chat is entered. */
readTerminalScreen?: () => string | null
}
export type NativeChatComposerHandle = {
focus: () => boolean
insertTypedText: (text: string) => boolean
/** Routes pane-level paste events back to the composer field. */
handlePasteEvent: (event: {
clipboardData: DataTransfer | null
preventDefault: () => void
defaultPrevented: boolean
}) => void
/** Pastes clipboard content when no DOM paste event is available. */
pasteFromClipboard: () => void
}
@@ -1,8 +1,8 @@
// Module-level cache for the composer's in-progress draft text, keyed by the
// same scope as image attachments (targetPtyId ?? terminalTabId). The composer
// unmounts when the pane toggles back to the hosted terminal, so without this
// the typed-but-unsent draft would be lost on every TUI/GUI round-trip. Mirrors
// the attachment cache so both halves of an unsent message survive the toggle.
// same stable pane scope as image attachments. The composer unmounts when the
// pane toggles back to the hosted terminal, so without this the typed-but-unsent
// draft would be lost on every TUI/GUI round-trip. Mirrors the attachment cache
// so both halves of an unsent message survive toggles and reconnects.
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
@@ -73,12 +73,32 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: 'agent-leaf',
activeLeafId: 'shell-leaf',
chatLeafStillMounted: true,
chatLeafIsEligible: true,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: 'agent-leaf', exitChat: false })
})
it('keeps chat attached through a transient eligibility loss and reconnect', () => {
const disconnected = resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'agent-leaf',
activeLeafId: 'agent-leaf',
chatLeafStillMounted: true,
activeLeafIsEligible: false
})
expect(disconnected).toEqual({ chatLeafId: 'agent-leaf', exitChat: false })
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: disconnected.chatLeafId,
activeLeafId: 'agent-leaf',
chatLeafStillMounted: true,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'agent-leaf', exitChat: false })
})
it('moves chat to an eligible active sibling after its leaf closes', () => {
expect(
resolveNativeChatLeafRoute({
@@ -86,23 +106,21 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: 'closed-leaf',
activeLeafId: 'agent-sibling',
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'agent-sibling', exitChat: false })
})
it('moves chat to an eligible active sibling when its mounted leaf becomes ineligible', () => {
it('does not move chat when its mounted leaf temporarily becomes ineligible', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'stopped-agent',
activeLeafId: 'agent-sibling',
chatLeafStillMounted: true,
chatLeafIsEligible: false,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'agent-sibling', exitChat: false })
).toEqual({ chatLeafId: 'stopped-agent', exitChat: false })
})
it('exits chat rather than inheriting an active shell after close', () => {
@@ -112,25 +130,49 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: 'closed-agent',
activeLeafId: 'shell-leaf',
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: null, exitChat: true })
})
it('exits chat when its leaf becomes ineligible and the active leaf is a shell', () => {
it('keeps chat open when its mounted leaf loses agent evidence', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'stopped-agent',
activeLeafId: 'shell-leaf',
chatLeafStillMounted: true,
chatLeafIsEligible: false,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: 'stopped-agent', exitChat: false })
})
it('exits chat when the mounted agent has authoritatively returned to its shell', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'exited-agent',
activeLeafId: 'exited-agent',
chatLeafStillMounted: true,
activeLeafIsEligible: true,
chatLeafHasConfirmedAgentExit: true
})
).toEqual({ chatLeafId: null, exitChat: true })
})
it('moves chat to an eligible sibling after the owning agent exits', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'exited-agent',
activeLeafId: 'agent-sibling',
chatLeafStillMounted: true,
activeLeafIsEligible: true,
chatLeafHasConfirmedAgentExit: true
})
).toEqual({ chatLeafId: 'agent-sibling', exitChat: false })
})
it('attaches a tab-level chat request to the eligible active leaf', () => {
expect(
resolveNativeChatLeafRoute({
@@ -138,7 +180,6 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: null,
activeLeafId: 'active-agent',
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'active-agent', exitChat: false })
@@ -151,7 +192,6 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: 'restored-agent',
activeLeafId: null,
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: 'restored-agent', exitChat: false })
@@ -164,7 +204,6 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: 'agent-leaf',
activeLeafId: 'agent-leaf',
chatLeafStillMounted: true,
chatLeafIsEligible: true,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: null, exitChat: false })
@@ -71,24 +71,30 @@ export function resolveNativeChatLeafRoute(args: {
chatLeafId: string | null
activeLeafId: string | null
chatLeafStillMounted: boolean
chatLeafIsEligible: boolean
activeLeafIsEligible: boolean
chatLeafHasConfirmedAgentExit?: boolean
}): NativeChatLeafRoute {
if (!args.isChatViewMode) {
return { chatLeafId: null, exitChat: false }
}
if (args.chatLeafId && args.chatLeafStillMounted && args.chatLeafIsEligible) {
if (args.chatLeafId && args.chatLeafStillMounted && !args.chatLeafHasConfirmedAgentExit) {
// Why: agent/title evidence can disappear while local, SSH, or runtime
// transports reconnect. A mounted owning pane is not a terminal lifecycle
// event, so keep its chat surface until the pane itself is removed.
return { chatLeafId: args.chatLeafId, exitChat: false }
}
// Manager hydration can briefly have no active pane; preserve the requested
// mode until a concrete leaf exists instead of toggling it off during mount.
if (!args.activeLeafId) {
if (!args.activeLeafId && !args.chatLeafHasConfirmedAgentExit) {
return { chatLeafId: args.chatLeafId, exitChat: false }
}
if (args.activeLeafIsEligible) {
if (
args.activeLeafIsEligible &&
(!args.chatLeafHasConfirmedAgentExit || args.activeLeafId !== args.chatLeafId)
) {
return { chatLeafId: args.activeLeafId, exitChat: false }
}
// Why: closing or invalidating the chat-owning leaf must not move its composer
// onto a plain-shell sibling. Return the tab to terminal mode instead.
// Why: removing the owning leaf or confirming its agent exited must not leave
// the composer targeting a plain shell. Return the tab to terminal mode.
return { chatLeafId: null, exitChat: true }
}
@@ -0,0 +1,20 @@
import type { TuiAgent } from '../../../../shared/types'
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
export type NativeChatViewProps = {
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
terminalTabId: string
/** Specific split leaf this chat surface replaces. */
paneKey?: string
/** PTY bound to `paneKey`, used for composer and interactive-card sends. */
targetPtyId?: string | null
/** Launch-time agent hint from the TerminalTab, when Orca started one. */
launchAgent?: TuiAgent | null
/** Trusted title/foreground fallback for manually-started agents. */
resolvedAgent?: TuiAgent | null
/** Return this pane to the hosted terminal surface. */
onSwitchToTerminal?: () => void
/** Current xterm screen reader used to recover agent-reported session state. */
readTerminalScreen?: () => string | null
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
}
@@ -3,9 +3,9 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-ch
/**
* Composer draft state backed by the scope cache so a typed-but-unsent message
* survives the composer unmounting on a TUI/GUI toggle. `scopeKey` is the same
* key used for image attachments (targetPtyId ?? terminalTabId); when it changes
* (the composer is reused for a different pane) the cached draft is reloaded.
* survives the composer unmounting on a TUI/GUI toggle. `scopeKey` is the stable
* pane key also used for image attachments; when it changes (the composer is
* reused for a different pane) the cached draft is reloaded.
*/
export function useNativeChatDraft(scopeKey: string): {
draft: string
@@ -103,7 +103,8 @@ import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send
import { canToggleNativeChat } from '../native-chat/native-chat-availability'
import {
nativeChatLaunchAgentForLeaf,
resolveNativeChatLeafRoute
resolveNativeChatLeafRoute,
type NativeChatLeafRoute
} from '../native-chat/native-chat-leaf-routing'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution'
@@ -402,6 +403,7 @@ export default function TerminalPane({
} | null>(null)
const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false)
const [chatLeafId, setChatLeafId] = useState<string | null>(null)
const onAgentExitedRef = useRef<(leafId: string) => void>(() => {})
const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState<string | null | undefined>(
undefined
)
@@ -784,6 +786,43 @@ export default function TerminalPane({
resolveTitleAgentForLeaf
]
)
const applyNativeChatLeafRoute = useCallback(
(route: NativeChatLeafRoute): void => {
if (route.chatLeafId !== chatLeafId) {
setChatLeafId(route.chatLeafId)
}
if (route.exitChat && unifiedTabId) {
// Why: event/effect replay must not flip terminal mode back to chat.
setTabViewMode(unifiedTabId, 'terminal')
}
},
[chatLeafId, setTabViewMode, unifiedTabId]
)
const handleConfirmedAgentExit = useCallback(
(leafId: string): void => {
if (leafId !== chatLeafId) {
return
}
const panes = managerRef.current?.getPanes() ?? []
const activeLeafId = managerRef.current?.getActivePane()?.leafId ?? null
applyNativeChatLeafRoute(
resolveNativeChatLeafRoute({
isChatViewMode,
chatLeafId,
activeLeafId,
chatLeafStillMounted: panes.some((pane) => pane.leafId === chatLeafId),
activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId),
chatLeafHasConfirmedAgentExit: true
})
)
},
[applyNativeChatLeafRoute, chatLeafId, isChatEligibleForLeaf, isChatViewMode]
)
useEffect(() => {
// Why: transport callbacks must only observe committed chat ownership;
// render work can be replayed or discarded under concurrent React.
onAgentExitedRef.current = handleConfirmedAgentExit
}, [handleConfirmedAgentExit])
const canToggleChatForLeaf = useCallback(
(leafId: string | null): boolean => {
// Scope the "always allow toggling back" rule to the leaf actually showing
@@ -1530,6 +1569,7 @@ export default function TerminalPane({
isActiveRef,
isVisibleRef,
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore((store) => store.consumeSuppressedPtyExit),
@@ -1754,6 +1794,7 @@ export default function TerminalPane({
isActiveRef,
isVisibleRef,
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore.getState().consumeSuppressedPtyExit,
@@ -1790,6 +1831,7 @@ export default function TerminalPane({
clearTerminalTabUnread,
clearTerminalPaneUnread,
showRestoredSessionBanner,
onAgentExitedRef,
onPtyExitRef,
setCacheTimerStartedAt,
setRuntimePaneTitle,
@@ -2987,24 +3029,16 @@ export default function TerminalPane({
chatLeafId,
activeLeafId,
chatLeafStillMounted,
chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId),
activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId)
})
if (route.chatLeafId !== chatLeafId) {
setChatLeafId(route.chatLeafId)
}
if (route.exitChat && unifiedTabId) {
// Why: effect replay must not flip terminal mode back to chat.
setTabViewMode(unifiedTabId, 'terminal')
}
applyNativeChatLeafRoute(route)
}, [
isChatViewMode,
chatLeafId,
activePane?.leafId,
chatLeafStillMounted,
isChatEligibleForLeaf,
unifiedTabId,
setTabViewMode
applyNativeChatLeafRoute,
isChatEligibleForLeaf
])
const chatPane =
isChatViewMode && chatLeafId
@@ -48,13 +48,14 @@ export type PtyConnectionDeps = {
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onAgentExitedRef: React.RefObject<(leafId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void
markWorktreeUnread: (worktreeId: string) => void
markTerminalTabUnread: (tabId: string) => void
markTerminalPaneUnread: (paneKey: string) => void
@@ -539,6 +539,7 @@ function createDeps(overrides: Record<string, unknown> = {}) {
isActiveRef: { current: true },
isVisibleRef: { current: true },
onPtyExitRef: { current: vi.fn() },
onAgentExitedRef: { current: vi.fn() },
onPtyErrorRef: { current: vi.fn() },
clearTabPtyId: vi.fn(),
consumeSuppressedPtyExit: vi.fn(() => false),
@@ -2541,6 +2542,34 @@ describe('connectPanePty', () => {
expect(manager.closePane).not.toHaveBeenCalled()
})
it('rebinds a provider replacement without granting fresh-spawn exit protection', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('terminal-old')
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(createPane(1) as never, manager as never, deps as never)
const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as
| ((ptyId: string, replacedPtyId: string) => void)
| undefined
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
expect(onPtyRebind).toBeTypeOf('function')
expect(onPtyExit).toBeTypeOf('function')
onPtyRebind?.('terminal-reconnected', 'terminal-old')
onPtyExit?.('terminal-reconnected')
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'terminal-reconnected')
expect(deps.updateTabPtyId).toHaveBeenCalledWith(
'tab-1',
'terminal-reconnected',
'terminal-old'
)
expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('terminal-reconnected')
expect(manager.closePane).not.toHaveBeenCalled()
})
it('closes a split pane when an established PTY exits after output', async () => {
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@@ -18549,6 +18578,7 @@ describe('connectPanePty', () => {
agentExitedHandler()
expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null)
expect(deps.onAgentExitedRef.current).toHaveBeenCalledWith(LEAF_1)
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
})
@@ -2734,6 +2734,7 @@ export function connectPanePty(
options: {
seedInitialAgentStatus?: boolean
updateTabPtyId?: 'always' | 'if-missing'
replacePtyId?: string
sampleVisibleForegroundAgent?: boolean
} = {}
): void => {
@@ -2751,7 +2752,11 @@ export function connectPanePty(
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? []
if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) {
deps.updateTabPtyId(deps.tabId, ptyId)
if (options.replacePtyId) {
deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId)
} else {
deps.updateTabPtyId(deps.tabId, ptyId)
}
}
if (options.seedInitialAgentStatus) {
applyInitialAgentStatus()
@@ -2792,6 +2797,11 @@ export function connectPanePty(
// once the PTY exists, then let real hook events refine or complete it.
bindActivePanePty(ptyId, { seedInitialAgentStatus: true })
}
const onPtyRebind = (ptyId: string, replacedPtyId: string): void => {
// Why: provider handle rotation keeps the existing pane/session generation;
// replace its stale store identity without fresh-spawn exit semantics.
bindActivePanePty(ptyId, { replacePtyId: replacedPtyId })
}
// ─── Attention signal: BEL ────────────────────────────────────────────
//
// BEL (0x07) is the attention signal. A BEL raises tab- and worktree-level
@@ -3080,6 +3090,9 @@ export function connectPanePty(
}
}
const onAgentExited = (): void => {
// Why: eligibility can disappear transiently during reconnect, but a
// confirmed shell-title transition is authoritative for native-chat exit.
deps.onAgentExitedRef.current(pane.leafId)
clearSuppressedTitleSideEffects()
clearCommandInferredPaneAgent()
requestKnownDroidReconfirmation()
@@ -3305,6 +3318,7 @@ export function connectPanePty(
...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}),
onPtyExit: onExit,
onPtySpawn,
onPtyRebind,
...(mainSideEffectAuthority
? {}
: {
@@ -157,6 +157,8 @@ export type IpcPtyTransportOptions = {
onPtyExit?: (ptyId: string) => void
onTitleChange?: (title: string, rawTitle: string) => void
onPtySpawn?: (ptyId: string) => void
/** Rebind an existing pane after its provider replaces the PTY identity. */
onPtyRebind?: (ptyId: string, replacedPtyId: string) => void
onBell?: () => void
onAgentBecameIdle?: (title: string) => void
onAgentBecameWorking?: () => void
@@ -305,12 +305,18 @@ describe('createRemoteRuntimePtyTransport', () => {
it('re-derives the host session handle after a transport close instead of resubscribing the stale one', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { getAllOverrides, setFitOverride } =
await import('@/lib/pane-manager/mobile-fit-overrides')
const { getAllDrivers, setDriverForPty } =
await import('@/lib/pane-manager/mobile-driver-state')
const onPtySpawn = vi.fn()
const onPtyRebind = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-tab-1',
leafId: 'pane:1',
onPtySpawn
onPtySpawn,
onPtyRebind
})
transport.attach({
@@ -321,6 +327,8 @@ describe('createRemoteRuntimePtyTransport', () => {
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-1' })
setFitOverride('remote:env-1@@terminal-1', 'mobile-fit', 49, 20)
setDriverForPty('remote:env-1@@terminal-1', { kind: 'mobile', clientId: 'phone-1' })
// Why: while the tunnel was down the host re-minted this pane's handle;
// resubscribing the stale closure handle would bind the mirror to a
@@ -364,7 +372,13 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-2' })
)
expect(transport.getPtyId()).toContain('terminal-2')
expect(onPtySpawn).toHaveBeenCalledWith(expect.stringContaining('terminal-2'))
expect(onPtySpawn).not.toHaveBeenCalled()
expect(onPtyRebind).toHaveBeenCalledWith(
expect.stringContaining('terminal-2'),
expect.stringContaining('terminal-1')
)
expect([...getAllOverrides().keys()]).toEqual(['remote:env-1@@terminal-2'])
expect([...getAllDrivers().keys()]).toEqual(['remote:env-1@@terminal-2'])
})
it('retires the mirror when the host no longer publishes the surface after a transport close', async () => {
@@ -459,10 +473,270 @@ describe('createRemoteRuntimePtyTransport', () => {
)
})
it('retires stale host-owned terminal handles without surfacing pane errors', async () => {
it('keeps the regular TUI and draft through inventory failure and stale-handle reconnect', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const onPtyExit = vi.fn()
const onPtySpawn = vi.fn()
const onPtyRebind = vi.fn()
const onExit = vi.fn()
const onDisconnect = vi.fn()
const renderedScreen: string[] = []
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-tab-1',
leafId: 'pane:1',
onPtyExit,
onPtySpawn,
onPtyRebind
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-stale',
cols: 80,
rows: 24,
callbacks: {
onError,
onExit,
onDisconnect,
onData: (data) => renderedScreen.push(data),
onReplayData: (data) => renderedScreen.push(data)
}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const initialStreamId = latestSubscribePayload().streamId
const draft = 'QA regular reconnect draft - keep this unsent'
emitOutput(initialStreamId, draft)
let hostListCalls = 0
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'session.tabs.list') {
hostListCalls += 1
if (hostListCalls === 1) {
throw new Error('runtime reconnect in progress')
}
const terminal =
hostListCalls === 2
? 'terminal-stale'
: hostListCalls === 3
? null
: 'terminal-reconnected'
return {
ok: true,
result: {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: hostListCalls + 1,
activeGroupId: null,
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Claude Code',
isActive: true,
status: terminal ? 'ready' : 'pending-handle',
terminal
}
]
}
}
}
return { ok: true, result: {} }
})
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'error', streamId: initialStreamId, message: 'terminal_handle_stale' }
})
await vi.waitFor(() => expect(hostListCalls).toBeGreaterThanOrEqual(1))
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-stale' })
expect(onPtyExit).not.toHaveBeenCalled()
await vi.waitFor(
() => expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-reconnected' }),
{ timeout: 2_000 }
)
const replacementStreamId = latestSubscribePayload().streamId
emitSnapshot(replacementStreamId, draft)
expect(onError).not.toHaveBeenCalled()
expect(onPtyExit).not.toHaveBeenCalled()
expect(onPtySpawn).not.toHaveBeenCalled()
expect(onPtyRebind).toHaveBeenCalledOnce()
expect(onPtyRebind).toHaveBeenCalledWith(
'remote:env-1@@terminal-reconnected',
'remote:env-1@@terminal-stale'
)
expect(onExit).not.toHaveBeenCalled()
expect(onDisconnect).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected')
expect(transport.isConnected()).toBe(true)
expect(renderedScreen.at(-1)).toBe(draft)
expect(hostListCalls).toBe(4)
const subscribedTerminals = subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.flatMap((frame) => {
if (frame?.opcode !== TerminalStreamOpcode.Subscribe) {
return []
}
const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload)
return payload ? [payload.terminal] : []
})
expect(subscribedTerminals).toEqual(['terminal-stale', 'terminal-reconnected'])
})
it('reattaches from a later host snapshot after bounded replacement polling stops', async () => {
vi.useFakeTimers()
try {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const onPtyExit = vi.fn()
const onPtyRebind = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-tab-1',
leafId: 'pane:1',
onPtyExit,
onPtyRebind
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-stale',
cols: 80,
rows: 24,
callbacks: { onError }
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
let hostListCalls = 0
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'terminal.send') {
return {
ok: false,
error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }
}
}
if (args.method !== 'session.tabs.list') {
return { ok: true, result: {} }
}
hostListCalls += 1
return {
ok: true,
result: {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: hostListCalls + 1,
activeGroupId: null,
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Claude Code',
isActive: true,
status: 'ready',
terminal: 'terminal-stale'
}
]
}
}
})
subscriptionCallbacks?.onResponse({
ok: true,
result: {
type: 'error',
streamId: latestSubscribePayload().streamId,
message: 'terminal_handle_stale'
}
})
await vi.advanceTimersByTimeAsync(16_000)
expect(hostListCalls).toBeGreaterThan(1)
expect(hostListCalls).toBeLessThan(25)
const listTimeouts = runtimeCall.mock.calls
.map(([args]) => args)
.filter((args) => args.method === 'session.tabs.list')
.map((args) => args.timeoutMs as number)
expect(listTimeouts[0]).toBe(15_000)
expect(listTimeouts.every((timeoutMs) => timeoutMs > 0 && timeoutMs <= 15_000)).toBe(true)
expect(listTimeouts.at(-1)).toBeLessThanOrEqual(1_000)
expect(onError).not.toHaveBeenCalled()
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-stale')
expect(transport.isConnected()).toBe(true)
const handleEvents = await import('../../runtime/web-session-terminal-handle-events')
expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1)
const listCallsAfterBound = hostListCalls
await expect(transport.sendInputAccepted?.('retry while reconnecting')).resolves.toBe(false)
await vi.advanceTimersByTimeAsync(16_000)
// The accepted-snapshot listener already owns recovery. User input must
// not turn a bounded reconnect into recurring host-inventory polling.
expect(hostListCalls).toBe(listCallsAfterBound)
expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1)
handleEvents.queueAcceptedWebSessionTerminalSnapshot(
{
worktree: 'wt-1',
publicationEpoch: 'epoch-2',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Claude Code',
isActive: true,
status: 'ready',
terminal: 'terminal-after-timeout'
}
]
},
'env-1'
)
await vi.advanceTimersByTimeAsync(0)
await vi.waitFor(() =>
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-after-timeout' })
)
expect(onPtyRebind).toHaveBeenCalledWith(
'remote:env-1@@terminal-after-timeout',
'remote:env-1@@terminal-stale'
)
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-after-timeout')
expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(0)
const subscribedTerminals = subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.flatMap((frame) => {
if (frame?.opcode !== TerminalStreamOpcode.Subscribe) {
return []
}
const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload)
return payload ? [payload.terminal] : []
})
expect(subscribedTerminals).toEqual(['terminal-stale', 'terminal-after-timeout'])
} finally {
vi.useRealTimers()
}
})
it('coalesces concurrent stale errors for the handle that was replaced', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-tab-1',
@@ -474,19 +748,104 @@ describe('createRemoteRuntimePtyTransport', () => {
existingPtyId: 'remote:env-1@@terminal-stale',
cols: 80,
rows: 24,
callbacks: { onError }
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
let resolveHostList: (response: unknown) => void = () => {}
const hostListResponse = new Promise((resolve) => {
resolveHostList = resolve
})
let hostListCalls = 0
runtimeCall.mockImplementation((args: { method: string }) => {
if (args.method === 'terminal.send') {
return Promise.resolve({
ok: false,
error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }
})
}
if (args.method === 'session.tabs.list') {
hostListCalls += 1
return hostListResponse
}
return Promise.resolve({ ok: true, result: {} })
})
const sendInputAccepted = transport.sendInputAccepted
if (!sendInputAccepted) {
throw new Error('Expected acknowledged remote terminal input')
}
const sends = Promise.all([sendInputAccepted('first'), sendInputAccepted('second')])
await vi.waitFor(() => expect(hostListCalls).toBe(1))
await expect(sends).resolves.toEqual([false, false])
resolveHostList({
ok: true,
result: {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Claude Code',
isActive: true,
status: 'ready',
terminal: 'terminal-reconnected'
}
]
}
})
await vi.waitFor(() =>
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-reconnected' })
)
await Promise.resolve()
// Why: the second stale response belonged to terminal-stale. Replaying it
// against the replacement would add another polling loop and retire it.
expect(hostListCalls).toBe(1)
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected')
expect(transport.isConnected()).toBe(true)
})
it('still retires the regular TUI surface after an explicit terminal exit', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-tab-1',
leafId: 'pane:1',
onPtyExit
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-exited',
cols: 80,
rows: 24,
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const { streamId } = latestSubscribePayload()
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'error', streamId, message: 'terminal_handle_stale' }
result: {
type: 'error',
streamId: latestSubscribePayload().streamId,
message: 'terminal_exited'
}
})
expect(onError).not.toHaveBeenCalled()
expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-stale')
expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-exited')
expect(transport.getPtyId()).toBeNull()
expect(transport.isConnected()).toBe(false)
})
it('ignores stale stream end after reattaching a newer remote terminal', async () => {
@@ -33,18 +33,24 @@ import {
createRemoteRuntimeViewportBatcher
} from './remote-runtime-pty-batching'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides'
import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state'
import { replaceFitOverridePtyId, setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides'
import { replaceDriverPtyId, setDriverForPty } from '@/lib/pane-manager/mobile-driver-state'
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-terminal-surface-id'
import { listRemoteRuntimeSessionTabsDeduped } from '@/runtime/remote-runtime-session-tabs-inflight'
import { subscribeAcceptedWebSessionTerminalHandle } from '@/runtime/web-session-terminal-handle-events'
const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8
const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
const HOST_SESSION_ATTACH_POLL_MS = 150
const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
function isRemoteTerminalStaleMessage(message: string): boolean {
return message.includes('terminal_handle_stale')
}
function isRemoteTerminalGoneMessage(message: string): boolean {
return (
message.includes('terminal_handle_stale') ||
message.includes('terminal_exited') ||
message.includes('terminal_gone') ||
message.includes('no_connected_pty')
@@ -73,6 +79,7 @@ export function createRemoteRuntimePtyTransport(
activate,
onPtyExit,
onPtySpawn,
onPtyRebind,
onTitleChange,
onBell,
onAgentBecameIdle,
@@ -90,7 +97,9 @@ export function createRemoteRuntimePtyTransport(
let desiredViewport: { cols: number; rows: number } | null = null
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
let resubscribing = false
let resubscribeRequested = false
let resubscribeRequestedHandle: string | null = null
let resubscribeRequestedRequiresReplacement = false
let stopWaitingForPublishedHandle: (() => void) | null = null
let subscriptionGeneration = 0
let pendingViewportClaim = false
let pendingClaimInput = ''
@@ -185,8 +194,13 @@ export function createRemoteRuntimePtyTransport(
await new Promise((resolve) =>
setTimeout(resolve, Math.min(HOST_SESSION_ATTACH_POLL_MS, remainingMs))
)
const listed = await callRuntime<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree
const listed = await listRemoteRuntimeSessionTabsDeduped({
environmentId: currentRuntimeEnvironmentId,
worktreeId,
load: () =>
callRuntime<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree
})
})
const handle = findReadyHostSessionHandle(listed, hostTabId)
if (handle) {
@@ -199,14 +213,70 @@ export function createRemoteRuntimePtyTransport(
return null
}
async function listHostSessionHandle(hostTabId: string): Promise<string | null> {
async function waitForResubscribeHostSessionHandle(
hostTabId: string,
previousHandle: string,
requireReplacement: boolean
): Promise<string | null | undefined> {
if (!worktreeId) {
return null
}
const listed = await callRuntime<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree: toRuntimeWorktreeSelector(worktreeId)
})
return findReadyHostSessionHandle(listed, hostTabId)
const worktree = toRuntimeWorktreeSelector(worktreeId)
const startedAt = Date.now()
let pollMs = HOST_SESSION_ATTACH_POLL_MS
let lastListError: unknown = null
const finishWithUnknownLiveness = (): undefined => {
if (lastListError) {
console.warn(
'[remote-runtime-pty] host session inventory unavailable during reconnect:',
runtimeTerminalErrorMessage(lastListError)
)
}
// Why: a bounded wait without removal evidence is unknown liveness;
// keep the mounted pane for an external snapshot to reattach later.
return undefined
}
while (!destroyed && connected && handle === previousHandle) {
const requestRemainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt)
if (requestRemainingMs <= 0) {
return finishWithUnknownLiveness()
}
try {
const listed = await listRemoteRuntimeSessionTabsDeduped({
environmentId: currentRuntimeEnvironmentId,
worktreeId,
load: () =>
callRuntime<RuntimeMobileSessionTabsResult>(
'session.tabs.list',
{
worktree
},
requestRemainingMs
)
})
lastListError = null
const nextHandle = findReadyHostSessionHandle(listed, hostTabId)
if (nextHandle && (!requireReplacement || nextHandle !== previousHandle)) {
return nextHandle
}
if (!hasHostSessionTerminalSurface(listed, hostTabId)) {
return null
}
} catch (error) {
// Why: the session inventory can race the same runtime reconnect that
// invalidated the handle. Unknown liveness must not retire the pane.
lastListError = error
}
const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt)
if (remainingMs <= 0) {
return finishWithUnknownLiveness()
}
// Why: a stale response can precede publication of its replacement.
// Bounded backoff avoids retrying the known-stale handle in a hot loop.
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remainingMs)))
pollMs = Math.min(pollMs * 2, HOST_SESSION_REPLACEMENT_POLL_MAX_MS)
}
return undefined
}
async function attachHostSessionMirror(
@@ -244,12 +314,16 @@ export function createRemoteRuntimePtyTransport(
} satisfies PtyConnectResult
}
async function callRuntime<TResult>(method: string, params?: unknown): Promise<TResult> {
async function callRuntime<TResult>(
method: string,
params?: unknown,
timeoutMs = 15_000
): Promise<TResult> {
const response = await window.api.runtimeEnvironments.call({
selector: currentRuntimeEnvironmentId,
method,
params,
timeoutMs: 15_000
timeoutMs
})
return unwrapRuntimeRpcResult(response as RuntimeRpcResponse<TResult>)
}
@@ -392,6 +466,11 @@ export function createRemoteRuntimePtyTransport(
multiplexedStreamHandle = null
}
function clearPublishedHandleWait(): void {
stopWaitingForPublishedHandle?.()
stopWaitingForPublishedHandle = null
}
function isCurrentRemoteTerminal(targetHandle: string, targetPtyId: string | null): boolean {
return (
!destroyed &&
@@ -404,6 +483,7 @@ export function createRemoteRuntimePtyTransport(
function retireRemoteTerminalId(): void {
connected = false
clearPublishedHandleWait()
clearPendingViewportClaim()
const stalePtyId = remotePtyId
handle = null
@@ -414,6 +494,56 @@ export function createRemoteRuntimePtyTransport(
}
}
function rebindRemoteTerminalHandle(nextHandle: string): void {
clearPublishedHandleWait()
const replacedPtyId = remotePtyId
handle = nextHandle
remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId)
// Why: host handle rotation preserves the pane generation; only the store
// identity changes, without fresh-spawn or terminal-exit semantics.
if (replacedPtyId) {
replaceFitOverridePtyId(replacedPtyId, remotePtyId)
replaceDriverPtyId(replacedPtyId, remotePtyId)
onPtyRebind?.(remotePtyId, replacedPtyId)
}
}
function waitForPublishedHostSessionHandle(hostTabId: string, previousHandle: string): void {
if (!worktreeId) {
return
}
clearPublishedHandleWait()
stopWaitingForPublishedHandle = subscribeAcceptedWebSessionTerminalHandle(
{
environmentId: currentRuntimeEnvironmentId,
worktreeId,
hostTabId,
leafId
},
(update) => {
if (destroyed || !connected || handle !== previousHandle) {
clearPublishedHandleWait()
return
}
if (!update.surfacePresent) {
retireRemoteTerminalId()
return
}
if (!update.terminalHandle || update.terminalHandle === previousHandle) {
return
}
rebindRemoteTerminalHandle(update.terminalHandle)
const reboundHandle = handle
const reboundPtyId = remotePtyId
void subscribeToHandle().catch((error) => {
if (reboundHandle && isCurrentRemoteTerminal(reboundHandle, reboundPtyId)) {
handleRemoteTerminalError(error)
}
})
}
)
}
function handleRemoteTerminalError(error: unknown): void {
const message = runtimeTerminalErrorMessage(error)
if (message === REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE) {
@@ -421,10 +551,20 @@ export function createRemoteRuntimePtyTransport(
// flowing — informational, not fatal, so never surface a red xterm banner.
return
}
if (isRemoteTerminalStaleMessage(message)) {
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
// Why: reconnect can re-mint a mirrored pane's handle while its host tab
// remains alive. Keep xterm/composer state mounted while we re-resolve it.
closeMultiplexedStream()
scheduleResubscribeAfterTransportClose(true)
} else {
retireRemoteTerminalId()
}
return
}
if (isRemoteTerminalGoneMessage(message)) {
// Why: paired web clients consume host-published PTY handles. If the host
// retires one between snapshots, clear this mirror and wait for the next
// session-tabs update instead of surfacing a red xterm error.
// Why: an explicit terminal-gone response is terminal lifecycle evidence,
// unlike a replaceable stale handle observed during reconnect.
retireRemoteTerminalId()
return
}
@@ -435,12 +575,23 @@ export function createRemoteRuntimePtyTransport(
// handle (reconnect, epoch or PTY change). Re-derive it from the current
// session snapshot instead of resubscribing the stale closure value, which
// would mirror (and type into) whatever PTY now sits behind it (#7718).
async function resubscribeAfterTransportClose(previousHandle: string): Promise<void> {
async function resubscribeAfterTransportClose(
previousHandle: string,
requireReplacement: boolean
): Promise<void> {
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
const nextHandle = await listHostSessionHandle(toHostSessionTabId(tabId))
const hostTabId = toHostSessionTabId(tabId)
const nextHandle = await waitForResubscribeHostSessionHandle(
hostTabId,
previousHandle,
requireReplacement
)
if (destroyed || !connected || handle !== previousHandle) {
return
}
if (nextHandle === undefined) {
return
}
if (!nextHandle) {
// Why: the host no longer publishes this surface; retire quietly and
// let the next session-tabs snapshot drive respawn/removal.
@@ -448,25 +599,42 @@ export function createRemoteRuntimePtyTransport(
return
}
if (nextHandle !== previousHandle) {
handle = nextHandle
remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId)
onPtySpawn?.(remotePtyId)
rebindRemoteTerminalHandle(nextHandle)
}
}
clearPublishedHandleWait()
await subscribeToHandle()
}
function scheduleResubscribeAfterTransportClose(): void {
function scheduleResubscribeAfterTransportClose(requireReplacement = false): void {
if (destroyed || !connected || !handle) {
return
}
if (requireReplacement && stopWaitingForPublishedHandle) {
// Why: once bounded polling has handed recovery to accepted snapshots,
// repeated sends to the known-stale handle must not re-arm inventory RPCs.
return
}
if (resubscribing) {
resubscribeRequested = true
// Why: concurrent stale errors belong to the handle that produced them.
// Do not carry an old handle's replacement requirement onto its successor.
if (resubscribeRequestedHandle !== handle) {
resubscribeRequestedHandle = handle
resubscribeRequestedRequiresReplacement = requireReplacement
} else {
resubscribeRequestedRequiresReplacement ||= requireReplacement
}
return
}
resubscribing = true
const resubscribeHandle = handle
void resubscribeAfterTransportClose(resubscribeHandle)
clearPublishedHandleWait()
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
// Why: subscribe before polling so a fresh host snapshot cannot land in
// the gap between the bounded inventory loop and its event-driven fallback.
waitForPublishedHostSessionHandle(toHostSessionTabId(tabId), resubscribeHandle)
}
resubscribing = true
void resubscribeAfterTransportClose(resubscribeHandle, requireReplacement)
.catch((error) => {
if (!destroyed && connected && handle) {
clearPendingViewportClaim()
@@ -475,9 +643,12 @@ export function createRemoteRuntimePtyTransport(
})
.finally(() => {
resubscribing = false
if (resubscribeRequested) {
resubscribeRequested = false
scheduleResubscribeAfterTransportClose()
const pendingHandle = resubscribeRequestedHandle
const pendingRequiresReplacement = resubscribeRequestedRequiresReplacement
resubscribeRequestedHandle = null
resubscribeRequestedRequiresReplacement = false
if (!stopWaitingForPublishedHandle && pendingHandle && pendingHandle === handle) {
scheduleResubscribeAfterTransportClose(pendingRequiresReplacement)
}
})
}
@@ -688,6 +859,7 @@ export function createRemoteRuntimePtyTransport(
},
attach(options) {
clearPublishedHandleWait()
storedCallbacks = options.callbacks
currentRuntimeEnvironmentId =
getRemoteRuntimePtyEnvironmentId(options.existingPtyId) ?? runtimeEnvironmentId
@@ -728,6 +900,7 @@ export function createRemoteRuntimePtyTransport(
},
disconnect() {
clearPublishedHandleWait()
inputBatcher.flush()
inputBatcher.clear()
viewportBatcher.flush()
@@ -748,6 +921,7 @@ export function createRemoteRuntimePtyTransport(
},
detach() {
clearPublishedHandleWait()
inputBatcher.flush()
inputBatcher.clear()
viewportBatcher.flush()
@@ -268,13 +268,14 @@ type UseTerminalPaneLifecycleDeps = {
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onAgentExitedRef: React.RefObject<(leafId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void
markWorktreeUnread: (worktreeId: string) => void
markTerminalTabUnread: (tabId: string) => void
markTerminalPaneUnread: (paneKey: string) => void
@@ -538,6 +539,7 @@ export function useTerminalPaneLifecycle({
isActiveRef,
isVisibleRef,
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
clearTabPtyId,
consumeSuppressedPtyExit,
@@ -757,6 +759,7 @@ export function useTerminalPaneLifecycle({
isActiveRef,
isVisibleRef,
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
clearTabPtyId,
consumeSuppressedPtyExit,
@@ -5,6 +5,7 @@ import {
hydrateDrivers,
isPtyLocked,
onDriverChange,
replaceDriverPtyId,
setDriverForPty
} from './mobile-driver-state'
@@ -40,6 +41,16 @@ describe('mobile-driver-state', () => {
expect(getDriverForPty('pty-2')).toEqual({ kind: 'desktop' })
})
it('moves a presence lock when a provider replaces the PTY identity', () => {
setDriverForPty('pty-old', { kind: 'mobile', clientId: 'phone-1' })
replaceDriverPtyId('pty-old', 'pty-new')
expect(getDriverForPty('pty-old')).toEqual({ kind: 'idle' })
expect(getDriverForPty('pty-new')).toEqual({ kind: 'mobile', clientId: 'phone-1' })
expect([...getAllDrivers().keys()]).toEqual(['pty-new'])
})
it('hydrates driver snapshots and notifies affected listeners', () => {
setDriverForPty('pty-old', { kind: 'mobile', clientId: 'phone-old' })
const listener = vi.fn()
@@ -42,6 +42,19 @@ export function setDriverForPty(ptyId: string, driver: DriverState): void {
notifyChange({ ptyId, driver })
}
export function replaceDriverPtyId(replacedPtyId: string, ptyId: string): void {
const replaced = driverByPtyId.get(replacedPtyId)
if (!replaced) {
return
}
// Why: keep the presence lock conservative across handle rotation while
// removing the obsolete key that repeated reconnects would otherwise retain.
if (!driverByPtyId.has(ptyId)) {
setDriverForPty(ptyId, replaced)
}
setDriverForPty(replacedPtyId, { kind: 'idle' })
}
export function getDriverForPty(ptyId: string): DriverState {
return driverByPtyId.get(ptyId) ?? { kind: 'idle' }
}
@@ -9,7 +9,8 @@ import {
onOverrideChange,
hydrateOverrides,
getAllOverrides,
getMobileFitOverridePtyIds
getMobileFitOverridePtyIds,
replaceFitOverridePtyId
} from './mobile-fit-overrides'
afterEach(() => {
@@ -74,6 +75,16 @@ describe('setFitOverride / getFitOverrideForPty', () => {
expect(getFitOverrideForPty('pty-1')?.cols).toBe(49)
expect(getFitOverrideForPty('pty-2')?.cols).toBe(80)
})
it('moves a fit hold when a provider replaces the PTY identity', () => {
setFitOverride('pty-old', 'mobile-fit', 49, 20)
replaceFitOverridePtyId('pty-old', 'pty-new')
expect(getFitOverrideForPty('pty-old')).toBeNull()
expect(getFitOverrideForPty('pty-new')).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 })
expect([...getAllOverrides().keys()]).toEqual(['pty-new'])
})
})
// ---------------------------------------------------------------------------
@@ -66,6 +66,20 @@ export function setFitOverride(ptyId: string, mode: FitHoldMode, cols: number, r
})
}
export function replaceFitOverridePtyId(replacedPtyId: string, ptyId: string): void {
const replaced = overridesByPtyId.get(replacedPtyId)
if (!replaced) {
return
}
// Why: handle rotation is the same pane lifecycle. Preserve its hold until
// the replacement stream publishes authoritative state, without retaining
// an unreachable entry for every old handle.
if (!overridesByPtyId.has(ptyId)) {
setFitOverride(ptyId, replaced.mode, replaced.cols, replaced.rows)
}
setFitOverride(replacedPtyId, 'desktop-fit', replaced.cols, replaced.rows)
}
export function getPaneIdsForPty(ptyId: string): number[] {
const result: number[] = []
for (const [key, boundPtyId] of ptyIdByFitBindingKey) {
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import {
getRemoteRuntimeSessionTabsInFlightCountForTests,
listRemoteRuntimeSessionTabsDeduped
} from './remote-runtime-session-tabs-inflight'
const SNAPSHOT = {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
} satisfies RuntimeMobileSessionTabsResult
describe('remote runtime session-tabs in-flight requests', () => {
it('shares one request within an environment/worktree and evicts it after settlement', async () => {
let resolveLoad: (snapshot: RuntimeMobileSessionTabsResult) => void = () => {}
const load = vi.fn(
() =>
new Promise<RuntimeMobileSessionTabsResult>((resolve) => {
resolveLoad = resolve
})
)
const args = { environmentId: 'env-1', worktreeId: 'wt-1', load }
const first = listRemoteRuntimeSessionTabsDeduped(args)
const second = listRemoteRuntimeSessionTabsDeduped(args)
expect(load).toHaveBeenCalledOnce()
expect(getRemoteRuntimeSessionTabsInFlightCountForTests()).toBe(1)
resolveLoad(SNAPSHOT)
await expect(Promise.all([first, second])).resolves.toEqual([SNAPSHOT, SNAPSHOT])
expect(getRemoteRuntimeSessionTabsInFlightCountForTests()).toBe(0)
const followupLoad = vi.fn(async () => SNAPSHOT)
await listRemoteRuntimeSessionTabsDeduped({
...args,
load: followupLoad
})
expect(followupLoad).toHaveBeenCalledOnce()
expect(getRemoteRuntimeSessionTabsInFlightCountForTests()).toBe(0)
})
it('does not share requests across runtime or worktree ownership boundaries', async () => {
const load = vi.fn(async () => SNAPSHOT)
await Promise.all([
listRemoteRuntimeSessionTabsDeduped({
environmentId: 'env-1',
worktreeId: 'wt-1',
load
}),
listRemoteRuntimeSessionTabsDeduped({
environmentId: 'env-2',
worktreeId: 'wt-1',
load
}),
listRemoteRuntimeSessionTabsDeduped({
environmentId: 'env-1',
worktreeId: 'wt-2',
load
})
])
expect(load).toHaveBeenCalledTimes(3)
})
})
@@ -0,0 +1,28 @@
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
const inFlightBySession = new Map<string, Promise<RuntimeMobileSessionTabsResult>>()
export function listRemoteRuntimeSessionTabsDeduped(args: {
environmentId: string
worktreeId: string
load: () => Promise<RuntimeMobileSessionTabsResult>
}): Promise<RuntimeMobileSessionTabsResult> {
const key = `${args.environmentId}\u0000${args.worktreeId}`
const existing = inFlightBySession.get(key)
if (existing) {
return existing
}
// Why: one runtime snapshot answers every pane in the worktree, so split-pane
// reconnects should share the same in-flight inventory RPC.
const request = args.load().finally(() => {
if (inFlightBySession.get(key) === request) {
inFlightBySession.delete(key)
}
})
inFlightBySession.set(key, request)
return request
}
export function getRemoteRuntimeSessionTabsInFlightCountForTests(): number {
return inFlightBySession.size
}
@@ -69,6 +69,7 @@ import {
shouldSkipWebRuntimeWakeTerminalRespawn
} from './web-runtime-wake-terminal-respawn'
import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay'
import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events'
const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:'
@@ -199,6 +200,7 @@ export function shouldApplyWebSessionTabsSnapshot(
// freshness/mapping entries need explicit cleanup instead of waiting for
// a later replacement snapshot that may never arrive.
clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree)
queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId)
return true
}
if (snapshot.worktree === FLOATING_TERMINAL_WORKTREE_ID) {
@@ -230,6 +232,9 @@ export function shouldApplyWebSessionTabsSnapshot(
publicationEpoch: snapshot.publicationEpoch,
snapshotVersion: snapshot.snapshotVersion
})
// Why: a mounted mirror that exhausted bounded polling needs fresh host
// evidence without subscribing to every application-store write.
queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId)
return true
}
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import {
getWebSessionTerminalHandleSubscriberCountForTests,
queueAcceptedWebSessionTerminalSnapshot,
subscribeAcceptedWebSessionTerminalHandle
} from './web-session-terminal-handle-events'
function snapshot(
tabs: RuntimeMobileSessionTabsResult['tabs'],
worktree = 'wt-1'
): RuntimeMobileSessionTabsResult {
return {
worktree,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs
}
}
describe('accepted web-session terminal handle events', () => {
it('notifies only the matching runtime/worktree/pane and releases the listener', async () => {
const listener = vi.fn()
const unsubscribe = subscribeAcceptedWebSessionTerminalHandle(
{ environmentId: 'env-1', worktreeId: 'wt-1', hostTabId: 'tab-1', leafId: 'leaf-1' },
listener
)
queueAcceptedWebSessionTerminalSnapshot(snapshot([]), 'env-2')
queueAcceptedWebSessionTerminalSnapshot(snapshot([], 'wt-2'), 'env-1')
queueAcceptedWebSessionTerminalSnapshot(
snapshot([
{
type: 'terminal',
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Claude Code',
isActive: true,
status: 'ready',
terminal: 'terminal-replacement'
}
]),
'env-1'
)
await Promise.resolve()
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({
surfacePresent: true,
terminalHandle: 'terminal-replacement'
})
expect(getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1)
unsubscribe()
expect(getWebSessionTerminalHandleSubscriberCountForTests()).toBe(0)
})
it('distinguishes a pending handle from an explicitly removed surface', async () => {
const listener = vi.fn()
const unsubscribe = subscribeAcceptedWebSessionTerminalHandle(
{ environmentId: 'env-1', worktreeId: 'wt-1', hostTabId: 'tab-1', leafId: 'leaf-1' },
listener
)
queueAcceptedWebSessionTerminalSnapshot(
snapshot([
{
type: 'terminal',
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Claude Code',
isActive: true,
status: 'pending-handle',
terminal: null
}
]),
'env-1'
)
await Promise.resolve()
queueAcceptedWebSessionTerminalSnapshot(snapshot([]), 'env-1')
await Promise.resolve()
expect(listener.mock.calls).toEqual([
[{ surfacePresent: true, terminalHandle: null }],
[{ surfacePresent: false, terminalHandle: null }]
])
unsubscribe()
})
it('coalesces same-tick snapshots so only the newest accepted state is delivered', async () => {
const listener = vi.fn()
const unsubscribe = subscribeAcceptedWebSessionTerminalHandle(
{ environmentId: 'env-1', worktreeId: 'wt-1', hostTabId: 'tab-1', leafId: 'leaf-1' },
listener
)
const terminal = (handle: string): RuntimeMobileSessionTabsResult['tabs'][number] => ({
type: 'terminal',
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Claude Code',
isActive: true,
status: 'ready',
terminal: handle
})
queueAcceptedWebSessionTerminalSnapshot(snapshot([terminal('terminal-stale')]), 'env-1')
queueAcceptedWebSessionTerminalSnapshot(snapshot([terminal('terminal-current')]), 'env-1')
await Promise.resolve()
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({
surfacePresent: true,
terminalHandle: 'terminal-current'
})
unsubscribe()
})
})
@@ -0,0 +1,120 @@
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTerminalClientTab
} from '../../../shared/runtime-types'
export type WebSessionTerminalHandleUpdate = {
surfacePresent: boolean
terminalHandle: string | null
}
type TerminalHandleSubscriber = {
hostTabId: string
leafId: string | null
listener: (update: WebSessionTerminalHandleUpdate) => void
}
const subscribersBySession = new Map<string, Set<TerminalHandleSubscriber>>()
const pendingSnapshotBySession = new Map<
string,
{
snapshot: RuntimeMobileSessionTabsResult
eligibleSubscribers: Set<TerminalHandleSubscriber>
}
>()
function sessionKey(environmentId: string, worktreeId: string): string {
return `${environmentId}\u0000${worktreeId}`
}
function resolveSubscriberUpdate(
snapshot: RuntimeMobileSessionTabsResult,
subscriber: TerminalHandleSubscriber
): WebSessionTerminalHandleUpdate {
const surfaces = snapshot.tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalClientTab =>
tab.type === 'terminal' &&
(tab.parentTabId === subscriber.hostTabId || tab.id === subscriber.hostTabId) &&
(!subscriber.leafId || tab.leafId === subscriber.leafId)
)
if (surfaces.length === 0) {
return { surfacePresent: false, terminalHandle: null }
}
const mirroredSurfaces = surfaces.filter(
(surface) => surface.parentTabId === subscriber.hostTabId
)
const readySurface =
mirroredSurfaces.find((surface) => surface.status === 'ready' && surface.isActive) ??
mirroredSurfaces.find((surface) => surface.status === 'ready')
return {
surfacePresent: true,
terminalHandle: readySurface?.terminal ?? null
}
}
export function subscribeAcceptedWebSessionTerminalHandle(
args: {
environmentId: string
worktreeId: string
hostTabId: string
leafId?: string | null
},
listener: (update: WebSessionTerminalHandleUpdate) => void
): () => void {
const key = sessionKey(args.environmentId, args.worktreeId)
const subscribers = subscribersBySession.get(key) ?? new Set<TerminalHandleSubscriber>()
const subscriber: TerminalHandleSubscriber = {
hostTabId: args.hostTabId,
leafId: args.leafId ?? null,
listener
}
subscribers.add(subscriber)
subscribersBySession.set(key, subscribers)
return () => {
subscribers.delete(subscriber)
if (subscribers.size === 0) {
subscribersBySession.delete(key)
}
}
}
export function queueAcceptedWebSessionTerminalSnapshot(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string
): void {
if (subscribersBySession.size === 0) {
return
}
const key = sessionKey(environmentId, snapshot.worktree)
const subscribers = subscribersBySession.get(key)
if (!subscribers || subscribers.size === 0) {
return
}
const pendingSnapshot = {
snapshot,
eligibleSubscribers: new Set(subscribers)
}
pendingSnapshotBySession.set(key, pendingSnapshot)
// Why: freshness checks can run inside a Zustand updater; defer transport
// callbacks and coalesce same-tick snapshots so only the newest fact can win.
queueMicrotask(() => {
if (pendingSnapshotBySession.get(key) !== pendingSnapshot) {
return
}
pendingSnapshotBySession.delete(key)
const currentSubscribers = subscribersBySession.get(key)
for (const subscriber of pendingSnapshot.eligibleSubscribers) {
if (currentSubscribers?.has(subscriber)) {
subscriber.listener(resolveSubscriberUpdate(pendingSnapshot.snapshot, subscriber))
}
}
})
}
export function getWebSessionTerminalHandleSubscriberCountForTests(): number {
let count = 0
for (const subscribers of subscribersBySession.values()) {
count += subscribers.size
}
return count
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { createTestStore, makeTab, seedStore } from './store-test-helpers'
describe('terminal PTY identity replacement', () => {
it('keeps repeated remote handle rotations bounded to the live identity', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
const firstPtyId = 'remote:env-1@@terminal-1'
const secondPtyId = 'remote:env-1@@terminal-2'
const thirdPtyId = 'remote:env-1@@terminal-3'
seedStore(store, {
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: firstPtyId })]
},
ptyIdsByTabId: { 'tab-1': [firstPtyId] },
suppressedPtyExitIds: { [firstPtyId]: true }
})
store.getState().updateTabPtyId('tab-1', secondPtyId, firstPtyId)
store.getState().updateTabPtyId('tab-1', thirdPtyId, secondPtyId)
const state = store.getState()
expect(state.ptyIdsByTabId['tab-1']).toEqual([thirdPtyId])
expect(state.tabsByWorktree[worktreeId][0]?.ptyId).toBe(thirdPtyId)
expect(state.lastKnownRelayPtyIdByTabId['tab-1']).toBe(thirdPtyId)
expect(state.suppressedPtyExitIds).toEqual({ [thirdPtyId]: true })
})
it('migrates stale PTY state when the host snapshot published the replacement first', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
const stalePtyId = 'remote:env-1@@terminal-stale'
const replacementPtyId = 'remote:env-1@@terminal-replacement'
seedStore(store, {
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: replacementPtyId })]
},
ptyIdsByTabId: { 'tab-1': [replacementPtyId] },
pendingCodexPaneRestartIds: { [stalePtyId]: true }
})
store.getState().updateTabPtyId('tab-1', replacementPtyId, stalePtyId)
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([replacementPtyId])
expect(store.getState().pendingCodexPaneRestartIds).toEqual({ [replacementPtyId]: true })
})
})
+42 -27
View File
@@ -648,7 +648,7 @@ export type TerminalSlice = {
opts?: { recordInteraction?: boolean }
) => void
setTabColor: (tabId: string, color: string | null) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void
clearTabPtyId: (tabId: string, ptyId?: string) => void
shutdownWorktreeTerminals: (
worktreeId: string,
@@ -2000,7 +2000,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
},
updateTabPtyId: (tabId, ptyId) => {
updateTabPtyId: (tabId, ptyId, replacedPtyId) => {
// Why: async spawn owners must perform provider teardown themselves, but
// this final guard prevents any late caller from recreating retired tab maps.
if (!isTerminalTabPresent(get(), tabId)) {
@@ -2016,8 +2016,13 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const hasLegacyPtyBinding = legacyRemotePtyId
? existingPtyIds.includes(legacyRemotePtyId)
: false
const nextPtyIds = hasLegacyPtyBinding
? [...new Set(existingPtyIds.map((id) => (id === legacyRemotePtyId ? ptyId : id)))]
const explicitReplacementPtyId = replacedPtyId !== ptyId ? replacedPtyId : undefined
const replacementPtyId =
explicitReplacementPtyId ?? (hasLegacyPtyBinding ? legacyRemotePtyId : null)
const boundReplacementPtyId =
replacementPtyId && existingPtyIds.includes(replacementPtyId) ? replacementPtyId : null
const nextPtyIds = boundReplacementPtyId
? [...new Set(existingPtyIds.map((id) => (id === boundReplacementPtyId ? ptyId : id)))]
: existingPtyIds.includes(ptyId)
? existingPtyIds
: [...existingPtyIds, ptyId]
@@ -2041,7 +2046,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// paths. In split panes, later pane spawns must not steal that
// primary binding from the original pane or remount/close flows can
// reattach the tab to the wrong PTY and appear to "reset" panes.
const currentTabPtyId = tab.ptyId === legacyRemotePtyId ? ptyId : tab.ptyId
const currentTabPtyId = tab.ptyId === replacementPtyId ? ptyId : tab.ptyId
const nextTabPtyId = currentTabPtyId ?? nextPtyIds[0] ?? null
const nextPendingActivationSpawn = consumePendingActivationSpawn(tab.pendingActivationSpawn)
if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) {
@@ -2066,46 +2071,56 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const isFirstPty = existingPtyIds.length === 0
const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId
const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn
const shouldRetainSuppressedExit = Boolean(
explicitReplacementPtyId &&
(s.suppressedPtyExitIds[ptyId] ||
(replacementPtyId && s.suppressedPtyExitIds[replacementPtyId]))
)
const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds }
delete nextSuppressedPtyExitIds[ptyId]
if (legacyRemotePtyId) {
delete nextSuppressedPtyExitIds[legacyRemotePtyId]
if (replacementPtyId) {
delete nextSuppressedPtyExitIds[replacementPtyId]
}
const hasLegacyPendingRestart = legacyRemotePtyId
? legacyRemotePtyId in s.pendingCodexPaneRestartIds
if (shouldRetainSuppressedExit) {
// Why: explicit handle rotation preserves the same terminal lifecycle;
// an intentional exit racing the rotation must stay suppressed once.
nextSuppressedPtyExitIds[ptyId] = true
}
const hasReplacementPendingRestart = replacementPtyId
? replacementPtyId in s.pendingCodexPaneRestartIds
: false
const hasLegacyRestartNotice = legacyRemotePtyId
? legacyRemotePtyId in s.codexRestartNoticeByPtyId
const hasReplacementRestartNotice = replacementPtyId
? replacementPtyId in s.codexRestartNoticeByPtyId
: false
const hasLegacyMigrationUnsupported = legacyRemotePtyId
? legacyRemotePtyId in s.migrationUnsupportedByPtyId
const hasReplacementMigrationUnsupported = replacementPtyId
? replacementPtyId in s.migrationUnsupportedByPtyId
: false
const nextPendingCodexPaneRestartIds = hasLegacyPendingRestart
const nextPendingCodexPaneRestartIds = hasReplacementPendingRestart
? { ...s.pendingCodexPaneRestartIds }
: s.pendingCodexPaneRestartIds
const nextCodexRestartNoticeByPtyId = hasLegacyRestartNotice
const nextCodexRestartNoticeByPtyId = hasReplacementRestartNotice
? { ...s.codexRestartNoticeByPtyId }
: s.codexRestartNoticeByPtyId
const nextMigrationUnsupportedByPtyId = hasLegacyMigrationUnsupported
const nextMigrationUnsupportedByPtyId = hasReplacementMigrationUnsupported
? { ...s.migrationUnsupportedByPtyId }
: s.migrationUnsupportedByPtyId
if (legacyRemotePtyId) {
if (hasLegacyPendingRestart) {
if (replacementPtyId) {
if (hasReplacementPendingRestart) {
nextPendingCodexPaneRestartIds[ptyId] = true
delete nextPendingCodexPaneRestartIds[legacyRemotePtyId]
delete nextPendingCodexPaneRestartIds[replacementPtyId]
}
if (hasLegacyRestartNotice) {
const legacyNotice = nextCodexRestartNoticeByPtyId[legacyRemotePtyId]
nextCodexRestartNoticeByPtyId[ptyId] ??= legacyNotice
delete nextCodexRestartNoticeByPtyId[legacyRemotePtyId]
if (hasReplacementRestartNotice) {
const replacedNotice = nextCodexRestartNoticeByPtyId[replacementPtyId]
nextCodexRestartNoticeByPtyId[ptyId] ??= replacedNotice
delete nextCodexRestartNoticeByPtyId[replacementPtyId]
}
if (hasLegacyMigrationUnsupported) {
const legacyMigrationUnsupported = nextMigrationUnsupportedByPtyId[legacyRemotePtyId]
if (hasReplacementMigrationUnsupported) {
const replacedMigrationUnsupported = nextMigrationUnsupportedByPtyId[replacementPtyId]
nextMigrationUnsupportedByPtyId[ptyId] ??= {
...legacyMigrationUnsupported,
...replacedMigrationUnsupported,
ptyId
}
delete nextMigrationUnsupportedByPtyId[legacyRemotePtyId]
delete nextMigrationUnsupportedByPtyId[replacementPtyId]
}
}
return {