diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5f8cd7fc52..131ffe9511 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10783,6 +10783,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 f8dc05fcfa..73a3a60faa 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7960,7 +7960,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 @@ -7968,6 +7968,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 @@ -7980,6 +7989,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 463648c6cd..cbc156e4f3 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, @@ -1317,7 +1342,6 @@ export default function TerminalPane({ setCacheTimerStartedAt, setRuntimePaneTitle, suppressPtyExit, - clearExitedPanePtyLayoutBinding, syncPanePtyLayoutBinding, tabId, updateTabPtyId, 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 1d7ba1281b..d3d7bd3766 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -352,19 +352,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 + }) } } @@ -855,10 +865,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) @@ -871,6 +901,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 () => { @@ -4152,6 +4183,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 a921d34102..2ce38c4a63 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1304,6 +1304,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 + }) + } + } + // Why: the transport's own exit handler (pty-transport.ts) normally makes // onExit run-at-most-once by clearing connected/ptyId + unregistering BEFORE // calling it. reconcileIfSessionDead drives onExit directly (bypassing that), @@ -1317,6 +1346,16 @@ export function connectPanePty( if (handledExitPtyId === ptyId) { return } + 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. + handledExitPtyId = ptyId + deps.clearTabPtyId(deps.tabId, ptyId) + deps.consumeSuppressedPtyExit(ptyId) + scheduleRuntimeGraphSync() + return + } handledExitPtyId = ptyId agentCompletionCoordinator.dispose() clearPanePtyFitBinding() @@ -1361,6 +1400,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-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 4d835b6dd7..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 @@ -342,9 +342,53 @@ 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 = () => { - throw new Error('old subscription reject was not captured') + const oldSubscription = { + reject: null as ((error: Error) => void) | null } const newStream = { streamId: 2, @@ -358,7 +402,7 @@ describe('createRemoteRuntimePtyTransport', () => { .mockImplementationOnce( () => new Promise((_resolve, reject) => { - rejectOldSubscription = reject + oldSubscription.reject = reject }) ) .mockResolvedValueOnce(newStream) @@ -389,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() 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 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..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 @@ -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,85 @@ 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' }) + }) + + 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 99d636e903..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 @@ -132,6 +132,9 @@ 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. 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/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..8d07259f7a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -0,0 +1,77 @@ +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 => { + // 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 + } + recoverVisibleTerminalWindowWake({ + manager, + isActive: isActiveRef.current + }) + 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]) +} 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( 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 }] }) ) }