diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 11db83265f8..9846d201cfe 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." }, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 6a48118b51a..25000521dbc 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 945c26701db..813f608748c 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -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 -} +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({ 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..3aead4c8dda 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,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 }) 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..1262f010ca5 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,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 } } diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts new file mode 100644 index 00000000000..2dce759075d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -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 +} 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..ef17bad747c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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(null) + const onAgentExitedRef = useRef<(leafId: string) => void>(() => {}) const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState( 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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 8e151c6ab5c..6805ccab3ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -48,13 +48,14 @@ export type PtyConnectionDeps = { isActiveRef: React.RefObject isVisibleRef: React.RefObject 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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index f63bff68716..70a79b26d31 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -539,6 +539,7 @@ function createDeps(overrides: Record = {}) { 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() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index c484f79a2ac..17fb6225ba3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 ? {} : { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index ecbb6db57c5..394e0d6306b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -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 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..09d163012d3 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 @@ -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 () => { 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..c93311ef453 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 @@ -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[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('session.tabs.list', { - worktree + const listed = await listRemoteRuntimeSessionTabsDeduped({ + environmentId: currentRuntimeEnvironmentId, + worktreeId, + load: () => + callRuntime('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 { + async function waitForResubscribeHostSessionHandle( + hostTabId: string, + previousHandle: string, + requireReplacement: boolean + ): Promise { if (!worktreeId) { return null } - const listed = await callRuntime('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( + '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(method: string, params?: unknown): Promise { + async function callRuntime( + method: string, + params?: unknown, + timeoutMs = 15_000 + ): Promise { const response = await window.api.runtimeEnvironments.call({ selector: currentRuntimeEnvironmentId, method, params, - timeoutMs: 15_000 + timeoutMs }) return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } @@ -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 { + async function resubscribeAfterTransportClose( + previousHandle: string, + requireReplacement: boolean + ): Promise { 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() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index e1fe139d445..90bdd83d304 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -268,13 +268,14 @@ type UseTerminalPaneLifecycleDeps = { isActiveRef: React.RefObject isVisibleRef: React.RefObject 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, diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts index b1a3c49fef4..a222e0f2fcf 100644 --- a/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts +++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts @@ -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() diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts index 504a4c23a64..689b3813288 100644 --- a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts +++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts @@ -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' } } diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts index 9d264c4eacc..fe8881e9667 100644 --- a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts +++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts @@ -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']) + }) }) // --------------------------------------------------------------------------- diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts index fc287f45663..b4c16e8b979 100644 --- a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts +++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts @@ -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) { diff --git a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts new file mode 100644 index 00000000000..ba66f4fcf17 --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts @@ -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((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) + }) +}) diff --git a/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts new file mode 100644 index 00000000000..b5b27029502 --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-session-tabs-inflight.ts @@ -0,0 +1,28 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' + +const inFlightBySession = new Map>() + +export function listRemoteRuntimeSessionTabsDeduped(args: { + environmentId: string + worktreeId: string + load: () => Promise +}): Promise { + 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 +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 41fbb2c8fe1..3833eea19c1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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 } diff --git a/src/renderer/src/runtime/web-session-terminal-handle-events.test.ts b/src/renderer/src/runtime/web-session-terminal-handle-events.test.ts new file mode 100644 index 00000000000..ebf3616faf8 --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-handle-events.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-handle-events.ts b/src/renderer/src/runtime/web-session-terminal-handle-events.ts new file mode 100644 index 00000000000..2fb22b8437b --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-handle-events.ts @@ -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>() +const pendingSnapshotBySession = new Map< + string, + { + snapshot: RuntimeMobileSessionTabsResult + eligibleSubscribers: Set + } +>() + +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() + 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 +} diff --git a/src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts b/src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts new file mode 100644 index 00000000000..22b9e023623 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts @@ -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 }) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index a64a9a2c9e2..d6f8b196324 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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 } }, - 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 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 // 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 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 {