diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index f1f1794cc3b..a79a3040588 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -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( { render( { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + it('keeps the draft scope anchored to the pane while the PTY reconnects', () => { + const view = render( + + ) + + view.rerender( + + ) + view.rerender( + + ) + + 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( null} @@ -231,6 +269,7 @@ describe('NativeChatComposer', () => { render( @@ -266,6 +305,7 @@ describe('NativeChatComposer', () => { render( { render( { render( { render( 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(EMPTY_HISTORY) @@ -180,7 +143,7 @@ export const NativeChatComposer = forwardRef 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(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 } diff --git a/src/renderer/src/components/native-chat/NativeChatView.test.tsx b/src/renderer/src/components/native-chat/NativeChatView.test.tsx index 968ceb6a5a8..cc25dabc125 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.test.tsx @@ -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 & Pick) { return { @@ -33,9 +35,24 @@ function renderResolution( ) } +function DraftProbe({ paneKey, sessionId }: { paneKey: string; sessionId: string | null }) { + const { draft, setDraft } = useNativeChatDraft(paneKey) + return ( + + ) +} + 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 + ) => ( + + {(resolution) => ( + + )} + + ) + 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', diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 984a4919103..3d00b59bda2 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -430,6 +430,7 @@ function NativeChatResolvedView({ 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 +} diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 2a753744458..07e33774de9 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -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' diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts index 1d284d64b3e..dca309fcb64 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -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 }) diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index f381f589014..edfc8f65df2 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -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 } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-draft.ts b/src/renderer/src/components/native-chat/use-native-chat-draft.ts index 3ba14595e28..6de88b8fda3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-draft.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-draft.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8bccda3aa61..603c2bb9ada 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -2987,7 +2987,6 @@ export default function TerminalPane({ chatLeafId, activeLeafId, chatLeafStillMounted, - chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId), activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) }) if (route.chatLeafId !== chatLeafId) { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 60b64c4a5be..78c7c43548f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index d6ae8b4cf7d..829c30106a7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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 }