diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4498dbe6bef..323a3dc497e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1562,6 +1562,7 @@ function TerminalPane( worktreeId, cwd, startup: CODEX_ACCOUNT_RESTART_STARTUP, + mountFollowsTerminalPark: false, paneTransportsRef, paneMode2031Ref, paneKittyKeyboardModesRef, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 18cae8d72c2..b2b5163ac93 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -48,6 +48,9 @@ export type PtyConnectionDeps = { } | null restoredLeafId?: string | null restoredPtyIdByLeafId?: Record + /** Park intent sampled at render time, before the host disposes the tab's + * watchers; consumed once the restored layout has been replayed. */ + mountFollowsTerminalPark: boolean paneTransportsRef: React.RefObject> paneMode2031Ref: React.RefObject> /** Per-pane mirror of the kitty keyboard flags the pane's application 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 fb6a97b8de6..37a000f16a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -62,18 +62,6 @@ async function drainFakeTimerWork(limit = 20): Promise { vi.clearAllTimers() } -// Why: a reveal remount reads the still-live park watcher entry at connect time to -// tell itself apart from an in-place reattach; the host disposes it a beat later. -async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise { - const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry') - parkedWatchersByTabId.set(tabId, { - worktreeId, - tabPtyId: ptyId, - paneIdByPtyId: new Map([[ptyId, 1]]), - disposersByPtyId: new Map([[ptyId, () => {}]]) - }) -} - async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise { let iterations = 0 while (pendingTimeouts.length > 0) { @@ -624,6 +612,7 @@ function createDeps(overrides: Record = {}) { startup: null, restoredLeafId: null, restoredPtyIdByLeafId: {}, + mountFollowsTerminalPark: false, paneTransportsRef: { current: new Map() }, paneMode2031Ref: { current: new Map() }, paneKittyKeyboardModesRef: { current: new Map() }, @@ -3148,6 +3137,39 @@ describe('connectPanePty', () => { expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) }) + it('routes terminal input through deferPtyInput so the host can withhold it', async () => { + // Regression: inlining forwardPtyInput into onData dropped this hop, silently disabling link-click mouse suppression. + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + transportFactoryQueue.push(transport) + const deferPtyInput = vi.fn() + + connectPanePty(pane as never, createManager(1) as never, createDeps({ deferPtyInput }) as never) + await flushAsyncTicks() + sendTerminalInputThroughPane(pane, 'a') + + expect(deferPtyInput).toHaveBeenCalledWith(1, 'a', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalled() + + const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void + forward('a') + expect(transport.sendInput).toHaveBeenCalledWith('a') + }) + + it('forwards terminal input directly when the host supplies no deferPtyInput', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + sendTerminalInputThroughPane(pane, 'a') + + expect(transport.sendInput).toHaveBeenCalledWith('a') + }) + it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) @@ -8758,7 +8780,7 @@ describe('connectPanePty', () => { expect(writes.join('')).not.toContain('ALT-FRAME-BODY') }) - it('drops a live daemon alt frame until a hidden pane has a final grid', async () => { + it('keeps a daemon alt frame at its capture grid while the target grid is unknown', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => @@ -8796,7 +8818,8 @@ describe('connectPanePty', () => { const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) expect(writes.join('')).toContain('PREFIX-SCROLLBACK') - expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + expect(writes.join('')).toContain('ALT-FRAME-BODY') + expect(writes.filter((write) => write.includes('ALT-FRAME-BODY'))).toHaveLength(1) }) it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => { @@ -15446,9 +15469,8 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('pulses a skipped hidden alt frame when reveal keeps the snapshot grid', async () => { + it('keeps a hidden-output alt frame at the capture grid below the fit floor', async () => { const { connectPanePty } = await import('./pty-connection') - const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') const transport = createMockTransport('pty-id') let onData: ConnectCallbacks['onData'] transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -15470,41 +15492,36 @@ describe('connectPanePty', () => { scrollbackAnsi: 'preserved history' }) const pane = createPane(1) - let paneRect = createRect(0, 0) - pane.container.getBoundingClientRect = vi.fn(() => paneRect) - let xtermContainerDisplay = 'none' - const xtermContainer = new EventTarget() as HTMLElement - Object.defineProperty(xtermContainer, 'parentElement', { value: null }) - Object.defineProperty(xtermContainer, 'ownerDocument', { - value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + pane.terminal.cols = 80 + pane.terminal.rows = 24 + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() }) - ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + pane.container.getBoundingClientRect = vi.fn(() => createRect(4, 3)) const deps = createDeps({ isVisibleRef: { current: false } }) - const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(6) onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: hidden.length + 1 + }) ;(deps.isVisibleRef as { current: boolean }).current = true - onData?.('x', { seq: hidden.length + 1, rawLength: 1 }) + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) await flushAsyncTicks(20) - const writes = (pane.terminal.write as ReturnType).mock.calls.map( - (call) => call[0] - ) - expect(writes.join('')).not.toContain('TOO-WIDE-ALT-FRAME') - expect(writes.join('')).toContain('\x1b[?1004h\x1b[?25l') + expect(writes.join('')).toContain('TOO-WIDE-ALT-FRAME') + expect(writes.join('')).toContain('preserved history') + expect(writes.filter((write) => write.includes('TOO-WIDE-ALT-FRAME'))).toHaveLength(1) + expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40) expect(transport.resize).not.toHaveBeenCalled() - - xtermContainerDisplay = 'block' - paneRect = createRect(800, 600) - safeFit(pane as never) - await flushAsyncTicks(12) - - const pulseStart = transport.resize.mock.calls.findIndex( - ([cols, rows]) => cols === 119 && rows === 40 - ) - expect(pulseStart).toBeGreaterThanOrEqual(0) - expect(transport.resize.mock.calls[pulseStart + 1]).toEqual([120, 40]) - disposable.dispose() + binding.dispose() }) it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => { @@ -21401,7 +21418,6 @@ describe('connectPanePty', () => { alternateScreen: true, scrollbackAnsi: 'PARKED-SSH-SCROLLBACK\r\n' }) - await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] }, @@ -21419,6 +21435,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) as never @@ -21464,7 +21481,6 @@ describe('connectPanePty', () => { transportFactoryQueue.push(createMockTransport()) vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise) vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) - await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId, generation: 7 }] }, @@ -21491,6 +21507,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) as never @@ -21543,7 +21560,6 @@ describe('connectPanePty', () => { seq: 125, source: 'headless' }) - await parkTabForReveal('tab-1', foreignPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: foreignPtyId }] }, @@ -21560,6 +21576,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: foreignPtyId } }) as never @@ -21595,7 +21612,6 @@ describe('connectPanePty', () => { }) transportFactoryQueue.push(transport) vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise) - await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] }, @@ -21612,6 +21628,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) as never @@ -21649,7 +21666,6 @@ describe('connectPanePty', () => { source: 'headless' }) transportFactoryQueue.push(transport) - await parkTabForReveal('tab-1', remotePtyId) mockStoreState = { ...mockStoreState, runtimeStatusByEnvironmentId: new Map([ @@ -21665,7 +21681,7 @@ describe('connectPanePty', () => { const pane = createPane(1) const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps() + const deps = createDeps({ mountFollowsTerminalPark: true }) connectPanePty(pane as never, createManager(1) as never, deps as never) for (let step = 0; step < 30; step += 1) { @@ -21680,7 +21696,7 @@ describe('connectPanePty', () => { expect(transport.getPtyId).toHaveReturnedWith(remotePtyId) }) - it('restores a local main-model snapshot after a contentless park reattach', async () => { + it('keeps a parked main-model alt frame at its capture grid while hidden', async () => { const { connectPanePty } = await import('./pty-connection') const localPtyId = 'global-floating-terminal@@terminal-1' const transport = createMockTransport(localPtyId) @@ -21691,13 +21707,15 @@ describe('connectPanePty', () => { transportFactoryQueue.push(transport) const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) getMainBufferSnapshot.mockResolvedValue({ - data: 'FLOATING-PARK-RESTORE-OK\r\n', + data: 'FLOATING-PARK-ALT-FRAME', + frameRestoreAnsi: '\x1b[?25l', cols: 113, rows: 32, seq: 558, - source: 'headless' + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'FLOATING-PARK-HISTORY\r\n' }) - await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal') mockStoreState = { ...mockStoreState, tabsByWorktree: { @@ -21715,8 +21733,10 @@ describe('connectPanePty', () => { } const pane = createPane(1) + pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) const deps = createDeps({ + mountFollowsTerminalPark: true, worktreeId: 'global-floating-terminal', restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: localPtyId } @@ -21733,7 +21753,55 @@ describe('connectPanePty', () => { expect.objectContaining({ sessionId: localPtyId }) ) expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId) - expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK') + expect(writes.join('')).toContain('FLOATING-PARK-HISTORY') + expect(writes.join('')).toContain('FLOATING-PARK-ALT-FRAME') + expect(writes.filter((write) => write.includes('FLOATING-PARK-ALT-FRAME'))).toHaveLength(1) + expect(pane.terminal.resize).toHaveBeenCalledWith(113, 32) + }) + + it('restores a parked SSH alt frame after StrictMode watcher disposal', async () => { + const { connectPanePty } = await import('./pty-connection') + const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-static-alt') + const transport = createMockTransport(sshPtyId) + transport.connect.mockImplementation(async () => { + transport.getPtyId.mockReturnValue(sshPtyId) + return { id: sshPtyId, isReattach: true, replay: '' } + }) + transportFactoryQueue.push(transport) + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ + data: '\x1b[0m\x1b[?1049h\x1b[HSTATIC-SSH-ALT-FRAME', + frameRestoreAnsi: '\x1b[0m\x1b[?1049h\x1b[?6l\x1b[1;21H', + cols: 100, + rows: 30, + seq: 512, + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'PRESERVED-SSH-HISTORY\r\n' + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId } + } + + const pane = createPane(1) + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + mountFollowsTerminalPark: true, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } + }) + connectPanePty(pane as never, createManager(1) as never, deps as never) + for (let step = 0; step < 30; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + + expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce() + expect(writes.join('')).toContain('PRESERVED-SSH-HISTORY') + expect(writes.filter((write) => write.includes('STATIC-SSH-ALT-FRAME'))).toHaveLength(1) }) it('falls back to relay replay when the SSH model snapshot stalls', async () => { @@ -21747,9 +21815,7 @@ describe('connectPanePty', () => { }) transportFactoryQueue.push(transport) vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {})) - // Why parked: only a reveal remount probes main's model — the pane reads the - // still-live park watcher entry at connect time to tell the two apart. - await parkTabForReveal('tab-1', sshPtyId) + // Why parked: only a reveal remount probes main's model. mockStoreState = { ...mockStoreState, @@ -21762,6 +21828,7 @@ describe('connectPanePty', () => { const pane = createPane(1) const { writes } = captureCallbackTerminalWrites(pane) const deps = createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f5d3a596cdf..b9a94ad2dfa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -280,7 +280,6 @@ import { setCommandCodeDoneSettleExecutor } from './command-code-done-settle' import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' -import { isTerminalTabParked } from './terminal-parked-watcher-registry' import { getExecutionHostIdForWorktree, getSettingsForWorktreeRuntimeOwner @@ -1096,10 +1095,7 @@ export function connectPanePty( // settles after remount must not remount its already-replaced successor. const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId) const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) - // Why sampled here: the host disposes this tab's park watcher in the effect - // that follows this mount, so connect time is the only moment a pane can tell - // a reveal remount from an in-place reattach. - let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId) + let mountFollowsTerminalPark = deps.mountFollowsTerminalPark let authoritativeReattachGeneration = 0 exposeE2eTerminalPtyOutputDebug() let disposed = false @@ -7303,9 +7299,7 @@ export function connectPanePty( skippedAltFrame = snapshot.alternateScreen === true && snapshot.frameRestoreAnsi !== undefined && - shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane), { - skipIfTargetUnknown: true - }) + shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane)) for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame: skippedAltFrame })) { @@ -8256,8 +8250,7 @@ export function connectPanePty( typeof snapshotFrameRestoreAnsi === 'string' && shouldSkipAltFrameForWidthMismatch( connectResult.snapshotCols, - readProposedTerminalCols(pane), - { skipIfTargetUnknown: true } + readProposedTerminalCols(pane) ) writeReplayData( daemonAltFrameSkippable @@ -8326,10 +8319,7 @@ export function connectPanePty( for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, { skipAltFrame: shouldSkipAltFrameForWidthMismatch( modelCols, - readProposedTerminalCols(pane), - { - skipIfTargetUnknown: true - } + readProposedTerminalCols(pane) ) })) { writeReplayData(replayChunk) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index 1f4b522040a..b2fb9b3de07 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -58,10 +58,10 @@ export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable): } /** - * Whether this tab is parked right now — the reveal remount's own mount effect - * runs before the host effect that disposes the watcher (child effects first), - * so a pane reading this at connect time can tell a park-reveal from an - * in-place reattach. Empty entries are pinned-close tombstones, not live parks. + * Whether this tab is parked right now — the reveal remount renders before the + * host effect that disposes the watcher, so a pane reading this at render time + * can tell a park-reveal from an in-place reattach. Empty entries are + * pinned-close tombstones, not live parks. */ export function isTerminalTabParked(tabId: string): boolean { return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0 diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index 9a3aa6621c5..042f6bed3d8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -89,18 +89,35 @@ describe('shouldSkipAltFrameForWidthMismatch', () => { expect(shouldSkipAltFrameForWidthMismatch(Number.NaN, 128)).toBe(false) expect(shouldSkipAltFrameForWidthMismatch(Number.POSITIVE_INFINITY, 128)).toBe(false) }) - - it('can conservatively skip a live frame until a hidden pane has a final grid', () => { - expect(shouldSkipAltFrameForWidthMismatch(135, undefined, { skipIfTargetUnknown: true })).toBe( - true - ) - expect( - shouldSkipAltFrameForWidthMismatch(undefined, undefined, { skipIfTargetUnknown: true }) - ).toBe(false) - }) }) describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { + it('restores the exact capture-grid alt frame while the target grid is unknown', async () => { + const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 }) + const snapshot = { + data: '\x1b[1;1HTOP---------\x1b[2;1HMIDDLE------\x1b[3;1HBOTTOM------', + frameRestoreAnsi: '\x1b[?25l', + alternateScreen: true, + scrollbackAnsi: 'history' + } + + try { + const skipAltFrame = shouldSkipAltFrameForWidthMismatch(12, undefined) + for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame })) { + await writeTerminal(terminal, chunk) + } + + expect( + Array.from({ length: 3 }, (_, row) => + terminal.buffer.active.getLine(row)?.translateToString(true) + ) + ).toEqual(['TOP---------', 'MIDDLE------', 'BOTTOM------']) + expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('history') + } finally { + terminal.dispose() + } + }) + it('keeps normal history and a clean alt grid through the real resize path', async () => { const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 }) const snapshot = { diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index 643ca0de925..aa41ff6d645 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -35,8 +35,8 @@ export function resolvePositiveTerminalDimensions( * The column count the post-replay fit will land on. Why not terminal.cols: a * pane that has not been fitted yet still reads xterm's 80-column default, so * comparing against it would drop frames whose width actually matches the - * container. Returns undefined when the pane cannot be measured; callers with - * a repaint owner can clear and defer, while offline preconnect paint cannot. + * container. Returns undefined when the pane cannot be measured, so replay can + * retain the frame at its capture grid until a final fit exists. */ export function readProposedTerminalCols(pane: ManagedPane): number | undefined { return readProposedPaneFitDimensions(pane)?.cols @@ -44,15 +44,14 @@ export function readProposedTerminalCols(pane: ManagedPane): number | undefined export function shouldSkipAltFrameForWidthMismatch( snapshotCols: number | undefined, - targetCols: number | undefined, - options: { skipIfTargetUnknown?: boolean } = {} + targetCols: number | undefined ): boolean { if (typeof snapshotCols !== 'number' || !Number.isFinite(snapshotCols) || snapshotCols <= 0) { return false } if (typeof targetCols !== 'number' || !Number.isFinite(targetCols) || targetCols <= 0) { - // A hidden pane has no final grid; its live app can repaint after the deferred fit. - return options.skipIfTargetUnknown === true + // Keep the frame at its capture grid until a real fit can replace that grid. + return false } // Fixed-grid alt rows clip at narrower columns; normal history remains reflowable. return snapshotCols > targetCols diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 74e53a0b6be..bc0582ca9fe 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -9,6 +9,7 @@ import { resolvePaneLinkCwd, resolvePaneSeedCwd, resolveQueuedInitialCwd, + replayLayoutWithOneShotParkIntent, resetTerminalKeyboardProtocolAfterInterrupt, retireMountedTerminalPaneSurface, shouldDetachPaneTransportOnUnmount, @@ -248,6 +249,35 @@ describe('splitPaneWithOneShotStartup', () => { }) }) +describe('replayLayoutWithOneShotParkIntent', () => { + it('exposes park intent to replayed panes and clears it before later splits', () => { + const deps = { mountFollowsTerminalPark: true } + const observedByReplayedPane: boolean[] = [] + + const restored = replayLayoutWithOneShotParkIntent(deps, () => { + observedByReplayedPane.push(deps.mountFollowsTerminalPark) + return 'restored-panes' + }) + + expect(restored).toBe('restored-panes') + expect(observedByReplayedPane).toEqual([true]) + // A split after replay reads the same deps object, so it must see ordinary reconnect semantics. + expect(deps.mountFollowsTerminalPark).toBe(false) + }) + + it('clears park intent even when layout replay throws', () => { + const deps = { mountFollowsTerminalPark: true } + + expect(() => + replayLayoutWithOneShotParkIntent(deps, () => { + throw new Error('replay failed') + }) + ).toThrow('replay failed') + + expect(deps.mountFollowsTerminalPark).toBe(false) + }) +}) + describe('applyTerminalScrollbackRowsToMountedPanes', () => { it('updates mounted pane xterm scrollback options only when needed', () => { const firstOptions = { scrollback: 1_000 } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 24ec93b974a..31928913df5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -130,6 +130,7 @@ import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-i import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' +import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -483,6 +484,19 @@ export function splitPaneWithOneShotStartup( } } +/** Scopes `deps.mountFollowsTerminalPark` to the restored-layout replay. */ +export function replayLayoutWithOneShotParkIntent( + deps: { mountFollowsTerminalPark: boolean }, + replayLayout: () => TRestored +): TRestored { + // Why: only panes reconstructed by this replay belong to the park reveal; later splits must use ordinary reconnect semantics. + try { + return replayLayout() + } finally { + deps.mountFollowsTerminalPark = false + } +} + export function shouldDetachPaneTransportOnUnmount(args: { tabStillExists: boolean tabId: string @@ -665,6 +679,7 @@ export function useTerminalPaneLifecycle({ const systemPrefersDarkRef = useRef(systemPrefersDark) systemPrefersDarkRef.current = systemPrefersDark const previousVisibleForReconcileRef = useRef(null) + const mountFollowsTerminalPark = useTerminalParkMountIntent(tabId) const linkProviderDisposablesRef = useRef(new Map()) const terminalHandleLinkDisposablesRef = useRef(new Map()) const linkifierClickPrimingDisposablesRef = useRef(new Map()) @@ -854,6 +869,7 @@ export function useTerminalPaneLifecycle({ worktreeId, cwd: startupCwd, startup: startupWithSetupSplitWait, + mountFollowsTerminalPark, paneTransportsRef, paneMode2031Ref, paneKittyKeyboardModesRef, @@ -1575,7 +1591,9 @@ export function useTerminalPaneLifecycle({ window.__paneManagers = window.__paneManagers ?? new Map() window.__paneManagers.set(tabId, manager) } - const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive) + const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () => + replayTerminalLayout(manager, initialLayoutRef.current, isActive) + ) const restoredBuffers = initialLayoutRef.current.buffersByLeafId restoreScrollbackBuffers( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx new file mode 100644 index 00000000000..7ab3ac8ae30 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx @@ -0,0 +1,76 @@ +/** @vitest-environment happy-dom */ +import { StrictMode, act, useEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { disposeParkedTabWatchers, parkedWatchersByTabId } from './terminal-parked-watcher-registry' +import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const TAB_ID = 'strict-park-reveal' +const observations: boolean[] = [] + +function Child(): null { + const mountFollowsTerminalPark = useTerminalParkMountIntent(TAB_ID) + useEffect(() => { + observations.push(mountFollowsTerminalPark) + }, [mountFollowsTerminalPark]) + return null +} + +function Parent() { + useEffect(() => { + disposeParkedTabWatchers(TAB_ID) + }, []) + return +} + +function parkTab(): void { + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'wt-1', + tabPtyId: 'pty-1', + paneIdByPtyId: new Map([['pty-1', 1]]), + disposersByPtyId: new Map([['pty-1', () => {}]]) + }) +} + +afterEach(() => { + observations.length = 0 + disposeParkedTabWatchers(TAB_ID) +}) + +describe('useTerminalParkMountIntent', () => { + it('survives StrictMode effect replay after the parent disposes the watcher', () => { + parkTab() + const container = document.createElement('div') + const root = createRoot(container) + + act(() => { + root.render( + + + + ) + }) + + expect(observations).toEqual([true, true]) + act(() => root.unmount()) + }) + + // Why: the lifecycle effect re-runs on cwd changes for the same component + // instance, so intent cached per instance would resupply a stale park reveal. + it('re-reads park intent when the same instance re-renders after disposal', () => { + parkTab() + const container = document.createElement('div') + const root = createRoot(container) + + act(() => root.render()) + expect(observations).toEqual([true]) + + disposeParkedTabWatchers(TAB_ID) + act(() => root.render()) + + expect(observations).toEqual([true, false]) + act(() => root.unmount()) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.ts b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.ts new file mode 100644 index 00000000000..dba51fcc8ea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.ts @@ -0,0 +1,9 @@ +import { isTerminalTabParked } from './terminal-parked-watcher-registry' + +// Why render time: the host effect that disposes the watcher runs after every +// child render, so reading here — not at connect time — is what lets a reveal +// remount still see the park. Caching it per instance would instead resupply a +// stale reveal on later same-instance effect re-runs (cwd change). +export function useTerminalParkMountIntent(tabId: string): boolean { + return isTerminalTabParked(tabId) +}