From f175030d390b0becac016648cfdda5584ab189c9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:17:14 -0700 Subject: [PATCH 1/7] Wire terminal-initiated worktree navigation to back-and-forth stack * Unify terminal-driven worktree activation and Cmd+J recency marking. * Record worktree visits in the back/forward history stack unless navigating history. --- src/renderer/src/hooks/useIpcEvents.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 94e815572f..73a29a0259 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -252,6 +252,12 @@ function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set worktree.id)) } +function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void { + if (!focusRuntimeTerminalSurface(tabId, leafId)) { + focusTerminalTabSurface(tabId, leafId) + } +} + function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { store.setActiveView('terminal') store.setActiveWorktree(worktreeId) @@ -263,12 +269,6 @@ function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): } } -function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void { - if (!focusRuntimeTerminalSurface(tabId, leafId)) { - focusTerminalTabSurface(tabId, leafId) - } -} - type TerminalSplitDirection = 'horizontal' | 'vertical' function insertLeafAfterSource( From 29c8fa78cba2db479338016b06a16f5466993b5d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:14:10 -0700 Subject: [PATCH 2/7] Resolve stale terminal focus and ignore late exits from old PTYs - Repair the active terminal pane leaf selection when a pane's PTY exits, preventing focus from being stranded on a dead pane. - Ensure stale, pruned, or unbound terminal leaves are not persisted as active in visual layouts or shutdown snapshots. - Ignore late exit notifications and subscription end events from old PTY transports or remote stream handles after a reconnect or rebind. --- src/main/runtime/orca-runtime.test.ts | 89 ++++++++++++++ src/main/runtime/orca-runtime.ts | 25 +++- .../components/terminal-pane/TerminalPane.tsx | 43 +++++-- .../terminal-pane/pty-connection.test.ts | 22 ++++ .../terminal-pane/pty-connection.ts | 9 ++ .../terminal-layout-leaf-ids.test.ts | 111 +++++++++++++++++- .../terminal-pane/terminal-layout-leaf-ids.ts | 63 +++++++--- .../terminal-shutdown-layout-capture.test.ts | 40 ++++++- .../terminal-shutdown-layout-capture.ts | 2 + 9 files changed, 373 insertions(+), 31 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3aa0b1f568..baabaf806f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10708,6 +10708,95 @@ describe('OrcaRuntimeService', () => { ]) }) + it('selects a visible active pane when terminal visual layout prunes a stale leaf', async () => { + const runtime = new OrcaRuntimeService(store) + const parentLayout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' } + }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:2': 'pty-2' } + } + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Split terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:2', + paneRuntimeId: 2, + ptyId: 'pty-2', + paneTitle: 'Live pane' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + 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: 'Stale pane', + parentLayout, + isActive: true + }, + { + type: 'terminal', + id: 'tab-1::pane:2', + parentTabId: 'tab-1', + leafId: 'pane:2', + title: 'Live pane', + parentLayout, + isActive: false + } + ] + } + ] + }) + + const result = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + + expect(result.visualLayouts).toMatchObject([ + { + worktreeId: TEST_WORKTREE_ID, + root: { + type: 'group', + tabs: [ + { + tabId: 'tab-1', + activeLeafId: 'pane:2', + panes: { + type: 'terminal', + leafId: 'pane:2', + active: true + } + } + ] + } + } + ]) + }) + it('omits stale browser session tabs that no longer have live webContents', async () => { const runtime = new OrcaRuntimeService(store) const tabList = vi.fn(() => ({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4c42134583..b72a56816f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7942,7 +7942,7 @@ export class OrcaRuntimeService { return null } const parentTabId = firstSurface.parentTabId - const activeLeafId = + const requestedActiveLeafId = firstSurface.parentLayout?.activeLeafId ?? surfaces.find((surface) => surface.isActive)?.leafId ?? firstSurface.leafId @@ -7950,6 +7950,15 @@ export class OrcaRuntimeService { type: 'leaf' as const, leafId: firstSurface.leafId } + const visibleLeafIds = this.collectVisibleTerminalLeafIds(root, parentTabId, summariesByLeafKey) + if (visibleLeafIds.length === 0) { + return null + } + const activeLeafId = + (requestedActiveLeafId && visibleLeafIds.includes(requestedActiveLeafId) + ? requestedActiveLeafId + : surfaces.find((surface) => surface.isActive && visibleLeafIds.includes(surface.leafId)) + ?.leafId) ?? visibleLeafIds[0]! const panes = this.buildTerminalVisualPane(root, parentTabId, activeLeafId, summariesByLeafKey) if (!panes) { return null @@ -7962,6 +7971,20 @@ export class OrcaRuntimeService { } } + private collectVisibleTerminalLeafIds( + node: TerminalPaneLayoutNode, + tabId: string, + summariesByLeafKey: ReadonlyMap + ): string[] { + if (node.type === 'leaf') { + return summariesByLeafKey.has(this.getLeafKey(tabId, node.leafId)) ? [node.leafId] : [] + } + return [ + ...this.collectVisibleTerminalLeafIds(node.first, tabId, summariesByLeafKey), + ...this.collectVisibleTerminalLeafIds(node.second, tabId, summariesByLeafKey) + ] + } + private buildTerminalVisualPane( node: TerminalPaneLayoutNode, tabId: string, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b8e8cf1ab8..2d8745e71e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -840,8 +840,8 @@ export default function TerminalPane({ persistLayoutSnapshot() }, [paneCount, paneTitles, persistLayoutSnapshot, terminalTab]) - const syncPanePtyLayoutBinding = useCallback( - (paneId: number, ptyId: string | null): void => { + const writePanePtyLayoutBinding = useCallback( + (paneId: number, ptyId: string | null, repairActiveLeafOnClear: boolean): void => { const existingLayout = useAppStore.getState().terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT const { ptyIdsByLeafId: _existingPtyIdsByLeafId, ...layoutWithoutPtyBindings } = existingLayout @@ -868,14 +868,36 @@ export default function TerminalPane({ const nextBindings = { ...existingBindings } delete nextBindings[leafId] - setTabLayout(tabId, { + const nextLayout = { ...layoutWithoutPtyBindings, ...(Object.keys(nextBindings).length > 0 ? { ptyIdsByLeafId: nextBindings } : {}) - }) + } + if ( + repairActiveLeafOnClear && + existingLayout.activeLeafId === leafId && + Object.keys(nextBindings).length > 0 + ) { + // Why: an active pane that lost its PTY would keep swallowing input if + // sibling bound panes are available; replacement/restart bookkeeping + // opts out so focus stays with the pane about to receive a fresh PTY. + nextLayout.activeLeafId = resolveTerminalLayoutActiveLeafId({ + root: nextLayout.root, + activeLeafId: nextLayout.activeLeafId, + ptyIdsByLeafId: nextBindings + }) + } + setTabLayout(tabId, nextLayout) }, [setTabLayout, tabId] ) + const syncPanePtyLayoutBinding = useCallback( + (paneId: number, ptyId: string | null): void => { + writePanePtyLayoutBinding(paneId, ptyId, false) + }, + [writePanePtyLayoutBinding] + ) + const clearExitedPanePtyLayoutBinding = useCallback( (paneId: number, exitedPtyId: string): void => { const existingLayout = useAppStore.getState().terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT @@ -1152,11 +1174,13 @@ export default function TerminalPane({ persistLayoutSnapshot() } - if (restoredLayout.activeLeafId) { - const activePaneId = manager.getNumericIdForLeaf(restoredLayout.activeLeafId) - if (activePaneId !== null) { - manager.setActivePane(activePaneId, { focus: isActive }) - } + const activePaneId = restoredLayout.activeLeafId + ? manager.getNumericIdForLeaf(restoredLayout.activeLeafId) + : null + const fallbackActivePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null + const nextActivePaneId = activePaneId ?? fallbackActivePaneId + if (nextActivePaneId !== null) { + manager.setActivePane(nextActivePaneId, { focus: isActive }) } }, [isActive, paneCount, persistLayoutSnapshot, restoredLayout]) @@ -1302,6 +1326,7 @@ export default function TerminalPane({ }, [ clearCodexRestartNotice, + clearExitedPanePtyLayoutBinding, clearRuntimePaneTitle, clearTabPtyId, cwd, 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 ac1c52a417..128b1c50f5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4090,6 +4090,28 @@ describe('connectPanePty', () => { expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) }) + it('ignores a late exit from a transport that no longer owns the pane', async () => { + const { connectPanePty } = await import('./pty-connection') + const oldTransport = createMockTransport('old-pty') + const replacementTransport = createMockTransport('new-pty') + transportFactoryQueue.push(oldTransport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + deps.paneTransportsRef.current.set(pane.id, replacementTransport) + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + onPtyExit?.('old-pty') + + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'old-pty') + expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('old-pty') + expect(manager.closePane).not.toHaveBeenCalled() + }) + it('clears launch config when an agent startup spawn produces no PTY', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e6930cfbd9..b507a816d9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1294,6 +1294,15 @@ export function connectPanePty( }) const onExit = (ptyId: string): void => { + const currentPaneTransport = deps.paneTransportsRef.current.get(pane.id) + if (currentPaneTransport && currentPaneTransport !== transport) { + // Why: an old transport can deliver a late exit after this pane has + // rebound to a replacement PTY; only clear ownership for the exited id. + deps.clearTabPtyId(deps.tabId, ptyId) + deps.consumeSuppressedPtyExit(ptyId) + scheduleRuntimeGraphSync() + return + } agentCompletionCoordinator.dispose() clearPanePtyFitBinding() const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts index 9be2f7d8e8..5f05376fd5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'vitest' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' import { normalizeTerminalLayoutSnapshot, + resolvePtyBoundActiveLeafId, resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' const LEAF_1 = '11111111-1111-4111-8111-111111111111' const LEAF_2 = '22222222-2222-4222-8222-222222222222' const LEAF_3 = '33333333-3333-4333-8333-333333333333' +const MISSING_LEAF = '99999999-9999-4999-8999-999999999999' function split(firstLeafId: string, secondLeafId: string): TerminalPaneLayoutNode { return { @@ -18,6 +20,29 @@ function split(firstLeafId: string, secondLeafId: string): TerminalPaneLayoutNod } } +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_2 }, + second: { type: 'leaf', leafId: LEAF_3 } + } + }, + activeLeafId: LEAF_2, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_2]: 'pty-2', + [LEAF_3]: 'pty-3' + } + } +} + describe('resolveTerminalLayoutActiveLeafId', () => { it('keeps the active leaf when it is still PTY-bound', () => { expect( @@ -128,4 +153,88 @@ describe('normalizeTerminalLayoutSnapshot active leaf repair', () => { expect(result.snapshot.activeLeafId).toBe(LEAF_2) expect(result.snapshot.ptyIdsByLeafId).toEqual({ [LEAF_2]: 'pty-2' }) }) + + it('repairs stale active leaf ids to the first bound leaf in the root layout', () => { + const layout = splitLayout() + layout.activeLeafId = MISSING_LEAF + + const normalized = normalizeTerminalLayoutSnapshot(layout) + + expect(normalized.changed).toBe(true) + expect(normalized.snapshot.activeLeafId).toBe(LEAF_1) + expect(normalized.snapshot.expandedLeafId).toBe(LEAF_3) + expect(normalized.snapshot.ptyIdsByLeafId).toEqual(layout.ptyIdsByLeafId) + }) + + it('clears stale expanded leaf ids without changing a valid active leaf', () => { + const layout = splitLayout() + layout.expandedLeafId = MISSING_LEAF + + const normalized = normalizeTerminalLayoutSnapshot(layout) + + expect(normalized.changed).toBe(true) + expect(normalized.snapshot.activeLeafId).toBe(LEAF_2) + expect(normalized.snapshot.expandedLeafId).toBeNull() + }) + + it('preserves valid active and expanded leaf ids', () => { + const layout = splitLayout() + + const normalized = normalizeTerminalLayoutSnapshot(layout) + + expect(normalized.changed).toBe(false) + expect(normalized.snapshot.activeLeafId).toBe(LEAF_2) + expect(normalized.snapshot.expandedLeafId).toBe(LEAF_3) + }) +}) + +describe('resolvePtyBoundActiveLeafId', () => { + it('preserves the active leaf when it still has a PTY binding', () => { + const layout = splitLayout() + + const activeLeafId = resolvePtyBoundActiveLeafId({ + root: layout.root, + activeLeafId: LEAF_2, + ptyIdsByLeafId: layout.ptyIdsByLeafId + }) + + expect(activeLeafId).toBe(LEAF_2) + }) + + it('moves active selection to the first bound layout leaf when the active PTY is gone', () => { + const layout = splitLayout() + + const activeLeafId = resolvePtyBoundActiveLeafId({ + root: layout.root, + activeLeafId: LEAF_2, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_3]: 'pty-3' + } + }) + + expect(activeLeafId).toBe(LEAF_1) + }) + + it('falls back to the current active leaf when no PTY bindings exist', () => { + const layout = splitLayout() + + const activeLeafId = resolvePtyBoundActiveLeafId({ + root: layout.root, + activeLeafId: LEAF_2, + ptyIdsByLeafId: undefined + }) + + expect(activeLeafId).toBe(LEAF_2) + }) + + it('uses a binding key when there is no root layout to inspect', () => { + const activeLeafId = resolvePtyBoundActiveLeafId({ + root: null, + activeLeafId: null, + ptyIdsByLeafId: { [LEAF_3]: 'pty-3' } + }) + + expect(activeLeafId).toBe(LEAF_3) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index 38e857bd26..81e4efd6af 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -114,6 +114,16 @@ export function resolveTerminalLayoutActiveLeafId(opts: { return activeLeafId && leafIdSet.has(activeLeafId) ? activeLeafId : leafIds[0] } +function getRemappedLeafId( + leafId: string | null | undefined, + rewrite: LeafIdRewrite +): string | null { + if (!leafId || rewrite.duplicatedInputLeafIds.has(leafId)) { + return null + } + return rewrite.nextLeafIdByInputLeafId.get(leafId) ?? null +} + export function normalizeTerminalLayoutSnapshot( snapshot: TerminalLayoutSnapshot | null | undefined ): { snapshot: TerminalLayoutSnapshot; changed: boolean } { @@ -146,27 +156,27 @@ export function normalizeTerminalLayoutSnapshot( nextLeafIdByInputLeafId.set(leafId, mintStablePaneId()) } } - if (!changed) { - const activeLeafId = resolveTerminalLayoutActiveLeafId({ - root: snapshot.root, - activeLeafId: snapshot.activeLeafId, - ptyIdsByLeafId: snapshot.ptyIdsByLeafId - }) - if (activeLeafId !== snapshot.activeLeafId) { - return { snapshot: { ...snapshot, activeLeafId }, changed: true } - } + const inputLeafIds = new Set(counts.keys()) + const activeLeafId = resolveTerminalLayoutActiveLeafId({ + root: snapshot.root, + activeLeafId: snapshot.activeLeafId, + ptyIdsByLeafId: snapshot.ptyIdsByLeafId + }) + const expandedLeafId = + snapshot.expandedLeafId && inputLeafIds.has(snapshot.expandedLeafId) + ? snapshot.expandedLeafId + : null + const selectionChanged = + activeLeafId !== snapshot.activeLeafId || expandedLeafId !== snapshot.expandedLeafId + if (!changed && !selectionChanged) { return { snapshot, changed: false } } const rewrite: LeafIdRewrite = { nextLeafIdByInputLeafId, duplicatedInputLeafIds } - const root = cloneLayoutWithLeafRewrite(snapshot.root, rewrite) - const remappedActiveLeafId = - snapshot.activeLeafId && !duplicatedInputLeafIds.has(snapshot.activeLeafId) - ? (nextLeafIdByInputLeafId.get(snapshot.activeLeafId) ?? null) - : firstLeafId(root) - const expandedLeafId = - snapshot.expandedLeafId && !duplicatedInputLeafIds.has(snapshot.expandedLeafId) - ? (nextLeafIdByInputLeafId.get(snapshot.expandedLeafId) ?? null) - : null + const root = changed ? cloneLayoutWithLeafRewrite(snapshot.root, rewrite) : snapshot.root + // Why: split panes can be restored after a leaf was closed elsewhere; stale + // selection ids must not strand focus on a missing pane. + const remappedActiveLeafId = getRemappedLeafId(activeLeafId, rewrite) ?? firstLeafId(root) + const remappedExpandedLeafId = getRemappedLeafId(expandedLeafId, rewrite) const ptyIdsByLeafId = remapLeafRecord(snapshot.ptyIdsByLeafId, rewrite) const buffersByLeafId = remapLeafRecord(snapshot.buffersByLeafId, rewrite) const scrollbackRefsByLeafId = remapLeafRecord(snapshot.scrollbackRefsByLeafId, rewrite) @@ -187,7 +197,7 @@ export function normalizeTerminalLayoutSnapshot( activeLeafId: remappedActiveLeafId, ptyIdsByLeafId }), - expandedLeafId, + expandedLeafId: remappedExpandedLeafId, ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), @@ -207,6 +217,21 @@ export function collectLeafIdsInOrder(node: TerminalPaneLayoutNode | null | unde return [...collectLeafIdsInOrder(node.first), ...collectLeafIdsInOrder(node.second)] } +export function resolvePtyBoundActiveLeafId(args: { + root: TerminalPaneLayoutNode | null | undefined + activeLeafId: string | null | undefined + ptyIdsByLeafId: Record | null | undefined +}): string | null { + if (!args.root) { + return Object.keys(args.ptyIdsByLeafId ?? {})[0] ?? args.activeLeafId ?? null + } + return resolveTerminalLayoutActiveLeafId({ + root: args.root, + activeLeafId: args.activeLeafId, + ptyIdsByLeafId: args.ptyIdsByLeafId ?? undefined + }) +} + export function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) } diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 19ca26f723..d3e8134848 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -53,7 +53,7 @@ function mockRootForPane(paneId: number, leafId: string = LEAF_ID): HTMLDivEleme return new MockHTMLElement({ firstElementChild: pane }) as unknown as HTMLDivElement } -function mockRootForSplit(firstPaneId: number, secondPaneId: number): HTMLDivElement { +function mockRootForSplit(firstPaneId = 1, secondPaneId = 2): HTMLDivElement { const first = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: String(firstPaneId), leafId: LEAF_ID }, @@ -328,4 +328,42 @@ describe('captureTerminalShutdownLayout', () => { [LEAF_ID_2]: 'pty-second' }) }) + + it('does not persist a no-PTY pane as active when another split pane is bound', async () => { + const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') + const paneWithoutPty = { + id: 1, + leafId: LEAF_ID, + stablePaneId: LEAF_ID, + terminal: { options: { scrollback: 1_000 } }, + serializeAddon: { + serialize: vi.fn(() => '') + } + } + const paneWithPty = { + id: 2, + leafId: LEAF_ID_2, + stablePaneId: LEAF_ID_2, + terminal: { options: { scrollback: 1_000 } }, + serializeAddon: { + serialize: vi.fn(() => '') + } + } + const manager = { + getPanes: vi.fn(() => [paneWithoutPty, paneWithPty]), + getActivePane: vi.fn(() => paneWithoutPty) + } + + const layout = captureTerminalShutdownLayout({ + manager: manager as never, + container: mockRootForSplit(), + expandedPaneId: null, + paneTransports: new Map([[2, { getPtyId: vi.fn(() => 'pty-2') }]]), + paneTitlesByPaneId: {}, + existingLayout: undefined + }) + + expect(layout.activeLeafId).toBe(LEAF_ID_2) + expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID_2]: 'pty-2' }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index 99d636e903..f38dbd4764 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -132,6 +132,8 @@ export function captureTerminalShutdownLayout({ currentLeafIds }) const ptyIdsByLeafId = { ...preservedPtyIdsByLeafId, ...livePtyIdsByLeafId } + // Why: shutdown snapshots can otherwise persist focus on a mounted pane whose + // transport was already cleared during PTY exit/reconnect cleanup. layout.activeLeafId = resolveTerminalLayoutActiveLeafId({ root: layout.root, activeLeafId: layout.activeLeafId, From 3511f368f81cbb1df3bd259f9bfb76fac9dfd39b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 25 Jun 2026 04:15:08 -0700 Subject: [PATCH 3/7] Prevent active terminal focus on dead panes and ignore stale PTY events - Redirect active keyboard focus to a live, PTY-backed sibling pane if the focused pane dies, fails to spawn, or is hydrated without a live connection. - Ignore stale data, replay messages, and subscription rejection errors from older PTY sessions after a transport has reconnected. - Avoid persisting stale PTY bindings and dead pane focus inside terminal shutdown layout snapshots. --- .../terminal-pane/pty-connection.test.ts | 51 ++++++++++++++---- .../terminal-pane/pty-connection.ts | 30 +++++++++++ .../remote-runtime-pty-transport.test.ts | 6 +-- .../terminal-shutdown-layout-capture.test.ts | 43 +++++++++++++++ .../terminal-shutdown-layout-capture.ts | 3 +- .../store/slices/terminals-hydration.test.ts | 52 +++++++++++++++++++ src/renderer/src/store/slices/terminals.ts | 15 +++++- 7 files changed, 183 insertions(+), 17 deletions(-) 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 128b1c50f5..9b55e9c869 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -349,19 +349,29 @@ function createPane(paneId: number) { } } -function createManager(paneCount = 1) { +function createManager(paneCount = 1, initialActivePaneId: number | null = null) { + let activePaneId = initialActivePaneId + const panes = Array.from({ length: paneCount }, (_, index) => ({ + id: index + 1, + leafId: leafIdForPane(index + 1) + })) return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), rebuildPaneWebgl: vi.fn(), - getPanes: vi.fn(() => - Array.from({ length: paneCount }, (_, index) => ({ - id: index + 1, - leafId: leafIdForPane(index + 1) - })) - ), + getPanes: vi.fn(() => panes), closePane: vi.fn(), - getActivePane: vi.fn<() => { id: number } | null>(() => null) + getActivePane: vi.fn<() => { id: number; leafId?: string } | null>(() => + activePaneId === null + ? null + : (panes.find((candidate) => candidate.id === activePaneId) ?? null) + ), + getNumericIdForLeaf: vi.fn((leafId: string) => { + return panes.find((candidate) => candidate.leafId === leafId)?.id ?? null + }), + setActivePane: vi.fn((paneId: number) => { + activePaneId = paneId + }) } } @@ -851,10 +861,30 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) - const manager = createManager(2) + const manager = createManager(2, 2) const deps = createDeps({ restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }, + clearExitedPanePtyLayoutBinding: vi.fn(() => { + mockStoreState = { + ...mockStoreState, + terminalLayoutsByTabId: { + ...mockStoreState.terminalLayoutsByTabId, + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 }, + ratio: 0.5 + }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-pane-1' } + } + } + } + }) }) connectPanePty(createPane(2) as never, manager as never, deps as never) @@ -867,6 +897,7 @@ describe('connectPanePty', () => { expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-pane-2') expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() expect(manager.closePane).not.toHaveBeenCalled() + expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true }) }) it('closes a split pane when an established PTY exits after output', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b507a816d9..f6a779fd75 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1293,6 +1293,35 @@ export function connectPanePty( } }) + const focusSurvivingPtyPaneAfterKeptExit = (): void => { + if (manager.getActivePane()?.id !== pane.id) { + return + } + const hasPtyBinding = (paneId: number): boolean => + Boolean(deps.paneTransportsRef.current.get(paneId)?.getPtyId()) + const repairedActiveLeafId = + useAppStore.getState().terminalLayoutsByTabId[deps.tabId]?.activeLeafId ?? null + const repairedActivePaneId = repairedActiveLeafId + ? manager.getNumericIdForLeaf(repairedActiveLeafId) + : null + const targetPaneId = + repairedActivePaneId !== null && + repairedActivePaneId !== pane.id && + hasPtyBinding(repairedActivePaneId) + ? repairedActivePaneId + : (manager + .getPanes() + .find((candidate) => candidate.id !== pane.id && hasPtyBinding(candidate.id))?.id ?? + null) + if (targetPaneId !== null) { + // Why: when a newborn split PTY dies before output/input, the pane stays + // mounted for diagnostics; move live focus to the sibling that still owns a PTY. + manager.setActivePane(targetPaneId, { + focus: deps.isActiveRef.current && deps.isVisibleRef.current + }) + } + } + const onExit = (ptyId: string): void => { const currentPaneTransport = deps.paneTransportsRef.current.get(pane.id) if (currentPaneTransport && currentPaneTransport !== transport) { @@ -1346,6 +1375,7 @@ export function connectPanePty( ) { // Why: a freshly split pane can lose its newborn PTY during setup; keep // the split visible so the failed session does not immediately collapse. + focusSurvivingPtyPaneAfterKeptExit() return } manager.closePane(pane.id) 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 4d835b6dd7..82a2369fa1 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 @@ -343,9 +343,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) it('ignores stale attach subscription rejection after reattaching a newer remote terminal', async () => { - let rejectOldSubscription: (error: Error) => void = () => { - throw new Error('old subscription reject was not captured') - } + let rejectOldSubscription: ((error: Error) => void) | null = null const newStream = { streamId: 2, sendInput: vi.fn(() => true), @@ -389,7 +387,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) await vi.waitFor(() => expect(subscribeTerminal).toHaveBeenCalledTimes(2)) - rejectOldSubscription(new Error('terminal_handle_stale')) + rejectOldSubscription?.(new Error('terminal_handle_stale')) await Promise.resolve() await Promise.resolve() diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index d3e8134848..07770304a2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -366,4 +366,47 @@ describe('captureTerminalShutdownLayout', () => { expect(layout.activeLeafId).toBe(LEAF_ID_2) expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID_2]: 'pty-2' }) }) + + it('does not preserve a stale prior PTY binding for active shutdown focus', async () => { + const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') + const paneWithoutPty = { + id: 1, + leafId: LEAF_ID, + stablePaneId: LEAF_ID, + terminal: { options: { scrollback: 1_000 } }, + serializeAddon: { + serialize: vi.fn(() => '') + } + } + const paneWithPty = { + id: 2, + leafId: LEAF_ID_2, + stablePaneId: LEAF_ID_2, + terminal: { options: { scrollback: 1_000 } }, + serializeAddon: { + serialize: vi.fn(() => '') + } + } + const manager = { + getPanes: vi.fn(() => [paneWithoutPty, paneWithPty]), + getActivePane: vi.fn(() => paneWithoutPty) + } + + const layout = captureTerminalShutdownLayout({ + manager: manager as never, + container: mockRootForSplit(), + expandedPaneId: null, + paneTransports: new Map([[2, { getPtyId: vi.fn(() => 'pty-2') }]]), + paneTitlesByPaneId: {}, + existingLayout: { + root: null, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'stale-pty', [LEAF_ID_2]: 'pty-2' } + } + }) + + expect(layout.activeLeafId).toBe(LEAF_ID_2) + expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID_2]: 'pty-2' }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index f38dbd4764..c0ee1de171 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -133,7 +133,8 @@ export function captureTerminalShutdownLayout({ }) const ptyIdsByLeafId = { ...preservedPtyIdsByLeafId, ...livePtyIdsByLeafId } // Why: shutdown snapshots can otherwise persist focus on a mounted pane whose - // transport was already cleared during PTY exit/reconnect cleanup. + // transport was already cleared during PTY exit/reconnect cleanup. Unlike + // scrollback, PTY bindings only preserve prior ids during a live attach gap. layout.activeLeafId = resolveTerminalLayoutActiveLeafId({ root: layout.root, activeLeafId: layout.activeLeafId, diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 5fc2323873..082184dfda 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -139,6 +139,58 @@ describe('hydrateWorkspaceSession', () => { }) }) + it('moves restored active focus from a dead split leaf to a pty-backed sibling', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-1' + const liveLeftLeafId = '9ee09218-72a5-4e1c-b075-729e937d4e29' + const liveRightLeafId = 'f5fc66b1-ec43-404b-b7b0-a06f0db34940' + const deadActiveLeafId = 'fbf63fd9-34d6-4387-9109-562f7c02bc4c' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + } + }) + + const session: WorkspaceSessionState = { + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabId: 'tab-1', + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: 'old-pty' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: liveLeftLeafId }, + second: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: liveRightLeafId }, + second: { type: 'leaf', leafId: deadActiveLeafId } + } + }, + activeLeafId: deadActiveLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { + [liveLeftLeafId]: 'daemon-session-left', + [liveRightLeafId]: 'daemon-session-right' + }, + buffersByLeafId: { + [deadActiveLeafId]: 'retained scrollback' + } + } + } + } + + store.getState().hydrateWorkspaceSession(session) + + // Why: restart can preserve scrollback for an exited pane while live siblings + // reattach. Keyboard focus must land on a PTY-backed pane, not the dead leaf. + expect(store.getState().terminalLayoutsByTabId['tab-1']?.activeLeafId).toBe(liveLeftLeafId) + }) + it('hydrates floating terminal tabs even though they are not repo worktrees', () => { const store = createTestStore() const session: WorkspaceSessionState = { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index b99b248a50..d997f1dcba 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -44,7 +44,10 @@ import { restorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' -import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { + normalizeTerminalLayoutSnapshot, + resolvePtyBoundActiveLeafId +} from '@/components/terminal-pane/terminal-layout-leaf-ids' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' @@ -2780,7 +2783,15 @@ export const createTerminalSlice: StateCreator const tab = Object.values(tabsByWorktree) .flat() .find((entry) => entry.id === tabId) - return [tabId, tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized] + const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized + const activeLeafId = sanitized.root + ? resolvePtyBoundActiveLeafId({ + root: sanitized.root, + activeLeafId: sanitized.activeLeafId, + ptyIdsByLeafId: sanitized.ptyIdsByLeafId + }) + : sanitized.activeLeafId + return [tabId, { ...sanitized, activeLeafId }] }) ) } From ca4294a032f2edebef6f1b27793d88665ae899ad Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:46:00 -0700 Subject: [PATCH 4/7] Clear pending input when attaching a different remote terminal handle When switching between different remote terminal handles, debounced input meant for the previous terminal could get incorrectly flushed and sent to the newly attached terminal. Fix this by clearing the batcher's pending state (text and byte counts) and invoking `inputBatcher.clear()` if the attached terminal handle changes. --- .../remote-runtime-pty-batching.ts | 2 + .../remote-runtime-pty-transport.test.ts | 44 +++++++++++++++++++ .../remote-runtime-pty-transport.ts | 8 +++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts index 8390affb5a..8e1ee1b0ab 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -50,6 +50,8 @@ export function createRemoteRuntimePtyTextBatcher( const clear = (): void => { clearTimer() + pending = '' + pendingBytes = 0 validationVersion += 1 validationTail = null } 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 82a2369fa1..8078125dc5 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 @@ -342,6 +342,50 @@ describe('createRemoteRuntimePtyTransport', () => { expect(transport.isConnected()).toBe(false) }) + it('drops pending input when attaching a different remote terminal handle', async () => { + vi.useFakeTimers() + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-old', + cols: 80, + rows: 24, + callbacks: {} + }) + expect(transport.sendInput('queued-for-old')).toBe(true) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-new', + cols: 80, + rows: 24, + callbacks: {} + }) + runtimeCall.mockClear() + + await vi.advanceTimersByTimeAsync(10) + + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.send' + }) + ) + } finally { + vi.useRealTimers() + } + }) + it('ignores stale attach subscription rejection after reattaching a newer remote terminal', async () => { let rejectOldSubscription: ((error: Error) => void) | null = null const newStream = { 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 534e4b64eb..7bf5ffc02f 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 @@ -507,7 +507,13 @@ export function createRemoteRuntimePtyTransport( storedCallbacks = options.callbacks currentRuntimeEnvironmentId = getRemoteRuntimePtyEnvironmentId(options.existingPtyId) ?? runtimeEnvironmentId - handle = getRemoteRuntimeTerminalHandle(options.existingPtyId) + const previousHandle = handle + const nextHandle = getRemoteRuntimeTerminalHandle(options.existingPtyId) + if (previousHandle && previousHandle !== nextHandle) { + // Why: debounced input is scoped by the current terminal handle at flush time. + inputBatcher.clear() + } + handle = nextHandle if (!handle) { connected = false remotePtyId = null From 7e3ba42a5ebbb694d69f7d7dac8e7cb143486daf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:37:06 -0700 Subject: [PATCH 5/7] Add window wake recovery hook for visible terminal panes - Extract and enhance window focus and visibility change recovery logic into a dedicated `useTerminalWindowWakeRecovery` hook. - Recover the xterm renderer, input surface, and WebGL texture atlases when macOS/display wakes or the window/tab regains focus. - Schedule recovery steps with requestAnimationFrame to ensure the terminal layout settles correctly. --- .../components/terminal-pane/TerminalPane.tsx | 1 - .../terminal-visibility-resume.ts | 27 +++++++ .../use-terminal-pane-global-effects.test.ts | 54 ++++++++++++++ .../use-terminal-pane-global-effects.ts | 31 +------- .../use-terminal-window-wake-recovery.ts | 74 +++++++++++++++++++ 5 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2d8745e71e..40ec91bddc 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1342,7 +1342,6 @@ export default function TerminalPane({ setCacheTimerStartedAt, setRuntimePaneTitle, suppressPtyExit, - clearExitedPanePtyLayoutBinding, syncPanePtyLayoutBinding, tabId, updateTabPtyId, diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 5feee00f70..a11dc4bb11 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -9,6 +9,7 @@ import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal- import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 +const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 export type TerminalHiddenReason = 'surface' | 'tab' @@ -35,6 +36,11 @@ type HideTerminalVisibilityResult = { renderingSuspended: boolean } +type RecoverVisibleTerminalWindowWakeArgs = { + manager: PaneManager + isActive: boolean +} + export function resumeTerminalVisibility({ manager, isActive, @@ -107,6 +113,27 @@ export function hideTerminalVisibility({ return { hiddenReason: null, renderingSuspended: false } } +export function recoverVisibleTerminalWindowWake({ + manager, + isActive +}: RecoverVisibleTerminalWindowWakeArgs): void { + // Why: macOS screensaver/display wake can leave xterm visible but with a + // stale renderer/input surface; Orca's own hidden-state resume never runs. + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) + } + manager.resumeRendering() + if (isActive) { + fitAndFocusPanes(manager) + } else { + fitPanes(manager) + } + enforceTerminalViewportIntents(manager) + resetAllTerminalWebglAtlases() + manager.refreshAllPanes?.() +} + function requestLightTabBacklogRecovery(manager: PaneManager): void { for (const pane of manager.getPanes()) { requestTerminalBacklogRecovery(pane.terminal) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 75245c1ddd..2917039133 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -674,6 +674,60 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) }) + it('recovers visible terminal rendering and input when the window regains focus', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + refreshAllPanes: vi.fn(), + suspendRendering: vi.fn(), + getActivePane: vi.fn(() => ({ id: 1, terminal })) + } + + registerManagerForReset(manager) + beginHookRender() + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + isSyncFitEnabled: true, + paneCount: 1, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + toggleExpandPane: vi.fn() + }) + + const focusListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([eventName]) => eventName === 'focus') + + expect(focusListener).toBeDefined() + const listener = focusListener?.[1] + if (typeof listener !== 'function') { + throw new Error('expected focus listener') + } + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + manager.refreshAllPanes.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + listener(new Event('focus')) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 64 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + }) + it('clears WebGL texture atlases when the active visible terminal document becomes visible', () => { let visibilityState: DocumentVisibilityState = 'hidden' const documentListeners = new Map() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 23a03049dc..4f7a336645 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -7,7 +7,6 @@ import { type PasteTerminalTextDetail } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' @@ -21,6 +20,7 @@ import { resumeTerminalVisibility, type TerminalHiddenReason } from './terminal-visibility-resume' +import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -80,6 +80,7 @@ export function useTerminalPaneGlobalEffects({ paneCount }) useTerminalContainerFitSync({ isVisible, isSyncFitEnabled, managerRef, containerRef }) + useTerminalWindowWakeRecovery({ isVisible, managerRef, isActiveRef, isVisibleRef }) useEffect(() => { const manager = managerRef.current @@ -128,34 +129,6 @@ export function useTerminalPaneGlobalEffects({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, isVisible, isWorktreeActive]) - useEffect(() => { - if (!isVisible) { - return - } - const recoverWebglAtlases = (): void => { - // Why: WebGL atlas corruption does not always raise context loss; window - // foregrounding is a low-cost recovery point. Visible terminals can be - // inactive in split groups, and same-config terminals share the atlas. - resetAllTerminalWebglAtlases() - } - const onFocus = (): void => recoverWebglAtlases() - const onVisibilityChange = (): void => { - if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - recoverWebglAtlases() - } - } - window.addEventListener('focus', onFocus) - if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { - document.addEventListener('visibilitychange', onVisibilityChange) - } - return () => { - window.removeEventListener('focus', onFocus) - if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { - document.removeEventListener('visibilitychange', onVisibilityChange) - } - } - }, [isVisible]) - useEffect(() => { const manager = managerRef.current const activePane = isActive && isVisible ? manager?.getActivePane() : null diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts new file mode 100644 index 0000000000..46b0099369 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -0,0 +1,74 @@ +import { useEffect } from 'react' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' + +type UseTerminalWindowWakeRecoveryArgs = { + isVisible: boolean + managerRef: React.RefObject + isActiveRef: React.RefObject + isVisibleRef: React.RefObject +} + +export function useTerminalWindowWakeRecovery({ + isVisible, + managerRef, + isActiveRef, + isVisibleRef +}: UseTerminalWindowWakeRecoveryArgs): void { + useEffect(() => { + if (!isVisible) { + return + } + let wakeRecoveryFrameId: number | null = null + const cancelScheduledWakeRecovery = (): void => { + if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') { + wakeRecoveryFrameId = null + return + } + cancelAnimationFrame(wakeRecoveryFrameId) + wakeRecoveryFrameId = null + } + const recoverVisibleWake = (): void => { + const manager = managerRef.current + if (!manager) { + return + } + recoverVisibleTerminalWindowWake({ + manager, + isActive: isActiveRef.current + }) + cancelScheduledWakeRecovery() + if (typeof requestAnimationFrame !== 'function') { + return + } + wakeRecoveryFrameId = requestAnimationFrame(() => { + wakeRecoveryFrameId = null + const settledManager = managerRef.current + if (!settledManager || !isVisibleRef.current) { + return + } + recoverVisibleTerminalWindowWake({ + manager: settledManager, + isActive: isActiveRef.current + }) + }) + } + const onFocus = (): void => recoverVisibleWake() + const onVisibilityChange = (): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'visible') { + recoverVisibleWake() + } + } + window.addEventListener('focus', onFocus) + if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('visibilitychange', onVisibilityChange) + } + return () => { + cancelScheduledWakeRecovery() + window.removeEventListener('focus', onFocus) + if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { + document.removeEventListener('visibilitychange', onVisibilityChange) + } + } + }, [isActiveRef, isVisible, isVisibleRef, managerRef]) +} From c0b3a12af99d4d46d943f034c623664695e7bbc7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:27:25 -0700 Subject: [PATCH 6/7] Fix mutable variable capturing in remote transport test Avoid mutating a local `let` variable from inside a Promise executor closure by wrapping the rejection callback in a `const` object. --- .../terminal-pane/remote-runtime-pty-transport.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 8078125dc5..be8ddca879 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 @@ -387,7 +387,9 @@ describe('createRemoteRuntimePtyTransport', () => { }) it('ignores stale attach subscription rejection after reattaching a newer remote terminal', async () => { - let rejectOldSubscription: ((error: Error) => void) | null = null + const oldSubscription = { + reject: null as ((error: Error) => void) | null + } const newStream = { streamId: 2, sendInput: vi.fn(() => true), @@ -400,7 +402,7 @@ describe('createRemoteRuntimePtyTransport', () => { .mockImplementationOnce( () => new Promise((_resolve, reject) => { - rejectOldSubscription = reject + oldSubscription.reject = reject }) ) .mockResolvedValueOnce(newStream) @@ -431,7 +433,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) await vi.waitFor(() => expect(subscribeTerminal).toHaveBeenCalledTimes(2)) - rejectOldSubscription?.(new Error('terminal_handle_stale')) + oldSubscription.reject?.(new Error('terminal_handle_stale')) await Promise.resolve() await Promise.resolve() From 09bf08aa2af58c5627c7e3eea4442dbcac06d377 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:55:41 -0700 Subject: [PATCH 7/7] Prevent duplicate terminal wake recovery on overlapping window events When window focus and visibility events fire concurrently, they can trigger multiple redundant wake recovery passes. - Return early if a wake recovery frame is already scheduled. - Keep the scheduled animation frame instead of canceling it to ensure we get a settled recovery pass. --- .../terminal-pane/use-terminal-window-wake-recovery.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 46b0099369..8d07259f7a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -29,6 +29,10 @@ export function useTerminalWindowWakeRecovery({ wakeRecoveryFrameId = null } const recoverVisibleWake = (): void => { + // Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass. + if (wakeRecoveryFrameId !== null) { + return + } const manager = managerRef.current if (!manager) { return @@ -37,7 +41,6 @@ export function useTerminalWindowWakeRecovery({ manager, isActive: isActiveRef.current }) - cancelScheduledWakeRecovery() if (typeof requestAnimationFrame !== 'function') { return }