fix(chat): preserve drafts through reconnects

This commit is contained in:
Brennan Benson
2026-07-17 15:51:49 -07:00
parent 2cb5d4e149
commit 113b1babfc
14 changed files with 343 additions and 93 deletions
@@ -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',
@@ -430,6 +430,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,23 +130,21 @@ 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: null, exitChat: true })
).toEqual({ chatLeafId: 'stopped-agent', exitChat: false })
})
it('attaches a tab-level chat request to the eligible active leaf', () => {
@@ -138,7 +154,6 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: null,
activeLeafId: 'active-agent',
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'active-agent', exitChat: false })
@@ -151,7 +166,6 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: 'restored-agent',
activeLeafId: null,
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: 'restored-agent', exitChat: false })
@@ -164,7 +178,6 @@ describe('resolveNativeChatLeafRoute', () => {
chatLeafId: 'agent-leaf',
activeLeafId: 'agent-leaf',
chatLeafStillMounted: true,
chatLeafIsEligible: true,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: null, exitChat: false })
@@ -71,13 +71,15 @@ export function resolveNativeChatLeafRoute(args: {
chatLeafId: string | null
activeLeafId: string | null
chatLeafStillMounted: boolean
chatLeafIsEligible: boolean
activeLeafIsEligible: boolean
}): NativeChatLeafRoute {
if (!args.isChatViewMode) {
return { chatLeafId: null, exitChat: false }
}
if (args.chatLeafId && args.chatLeafStillMounted && args.chatLeafIsEligible) {
if (args.chatLeafId && args.chatLeafStillMounted) {
// 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
@@ -88,7 +90,7 @@ export function resolveNativeChatLeafRoute(args: {
if (args.activeLeafIsEligible) {
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 chat-owning leaf must not move its composer onto a
// plain-shell sibling. Return the tab to terminal mode instead.
return { chatLeafId: null, exitChat: true }
}
@@ -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
@@ -2987,7 +2987,6 @@ export default function TerminalPane({
chatLeafId,
activeLeafId,
chatLeafStillMounted,
chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId),
activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId)
})
if (route.chatLeafId !== chatLeafId) {
@@ -459,10 +459,13 @@ describe('createRemoteRuntimePtyTransport', () => {
)
})
it('retires stale host-owned terminal handles without surfacing pane errors', async () => {
it('keeps the regular TUI surface and draft through stale-handle reconnect', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const onPtyExit = 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',
@@ -474,19 +477,97 @@ describe('createRemoteRuntimePtyTransport', () => {
existingPtyId: 'remote:env-1@@terminal-stale',
cols: 80,
rows: 24,
callbacks: { onError }
callbacks: {
onError,
onExit,
onDisconnect,
onData: (data) => renderedScreen.push(data),
onReplayData: (data) => renderedScreen.push(data)
}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const { streamId } = latestSubscribePayload()
const initialStreamId = latestSubscribePayload().streamId
const draft = 'QA regular reconnect draft - keep this unsent'
emitOutput(initialStreamId, draft)
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'session.tabs.list'
? {
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'
}
]
}
}
: { ok: true, result: {} }
)
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'error', streamId, message: 'terminal_handle_stale' }
result: { type: 'error', streamId: initialStreamId, message: 'terminal_handle_stale' }
})
await vi.waitFor(() =>
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-reconnected' })
)
const replacementStreamId = latestSubscribePayload().streamId
emitSnapshot(replacementStreamId, draft)
expect(onError).not.toHaveBeenCalled()
expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-stale')
expect(onPtyExit).not.toHaveBeenCalled()
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)
})
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())
subscriptionCallbacks?.onResponse({
ok: true,
result: {
type: 'error',
streamId: latestSubscribePayload().streamId,
message: 'terminal_exited'
}
})
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 () => {
@@ -42,9 +42,12 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
const HOST_SESSION_ATTACH_POLL_MS = 150
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')
@@ -421,10 +424,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()
} 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
}