From 46fd33b8cf9f37c3083a70024f7d3d3dc51071c8 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 24 Jun 2026 14:20:18 -0700 Subject: [PATCH] Preserve pending terminal restore on fast hide Co-authored-by: Orca --- .../components/terminal-pane/TerminalPane.tsx | 12 ++++-- .../use-terminal-pane-global-effects.test.ts | 2 + ...-terminal-scroll-visibility-memory.test.ts | 38 +++++++++++++++++++ .../use-terminal-scroll-visibility-memory.ts | 4 +- .../src/lib/pane-manager/pane-scroll.ts | 4 ++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 0e73343c4bc..fcc064a9bf1 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -72,7 +72,11 @@ import { } from '@/lib/pane-manager/mobile-driver-state' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' -import { captureScrollState, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' +import { + captureScrollState, + getPendingScrollRestoreState, + restoreScrollStateAfterLayout +} from '@/lib/pane-manager/pane-scroll' import { logTerminalScrollRestore, terminalScrollStateForDebug, @@ -730,7 +734,8 @@ export default function TerminalPane({ const scrollStatesByLeafId = isVisibleRef.current ? Object.fromEntries( currentPanes.map((pane) => { - const { bufferType, wasAtBottom, viewportY, baseY } = captureScrollState(pane.terminal) + const { bufferType, wasAtBottom, viewportY, baseY } = + getPendingScrollRestoreState(pane.terminal) ?? captureScrollState(pane.terminal) const current = { bufferType, wasAtBottom, viewportY, baseY } const previous = lastVisibleScrollStatesByLeafIdRef.current?.[pane.leafId] const stable = @@ -861,7 +866,8 @@ export default function TerminalPane({ } return Object.fromEntries( manager.getPanes().map((pane) => { - const { bufferType, wasAtBottom, viewportY, baseY } = captureScrollState(pane.terminal) + const { bufferType, wasAtBottom, viewportY, baseY } = + getPendingScrollRestoreState(pane.terminal) ?? captureScrollState(pane.terminal) const current = { bufferType, wasAtBottom, viewportY, baseY } const previous = lastVisibleScrollStatesByLeafIdRef.current?.[pane.leafId] const stable = 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 e6995005e53..434bf489387 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 @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ fitPanes: vi.fn(), focusActivePane: vi.fn(), flushTerminalOutput: vi.fn(), + getPendingScrollRestoreState: vi.fn(() => null), getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), pasteTerminalText: vi.fn(), @@ -70,6 +71,7 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ vi.mock('@/lib/pane-manager/pane-scroll', () => ({ captureScrollState: mocks.captureScrollState, + getPendingScrollRestoreState: mocks.getPendingScrollRestoreState, getTerminalOutputEpoch: mocks.getTerminalOutputEpoch, restoreScrollState: mocks.restoreScrollState, restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts index 6899213638b..3d7df32432f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ baseY: 0 })), flushTerminalOutput: vi.fn(), + getPendingScrollRestoreState: vi.fn((): unknown => null), getTerminalOutputEpoch: vi.fn(() => 1), isTerminalScrollRestoreInProgress: vi.fn(() => false) })) @@ -66,6 +67,7 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ vi.mock('@/lib/pane-manager/pane-scroll', () => ({ cancelDeferredScrollRestore: mocks.cancelDeferredScrollRestore, captureScrollState: mocks.captureScrollState, + getPendingScrollRestoreState: mocks.getPendingScrollRestoreState, getTerminalOutputEpoch: mocks.getTerminalOutputEpoch, isTerminalScrollRestoreInProgress: mocks.isTerminalScrollRestoreInProgress })) @@ -78,6 +80,7 @@ describe('useTerminalScrollVisibilityMemory', () => { resetHookRefs() vi.clearAllMocks() mocks.captureScrollState.mockReset() + mocks.getPendingScrollRestoreState.mockReset() mocks.getTerminalOutputEpoch.mockReset() mocks.isTerminalScrollRestoreInProgress.mockReset() mocks.captureScrollState.mockImplementation(() => ({ @@ -86,6 +89,7 @@ describe('useTerminalScrollVisibilityMemory', () => { viewportY: 0, baseY: 0 })) + mocks.getPendingScrollRestoreState.mockImplementation(() => null) mocks.getTerminalOutputEpoch.mockImplementation(() => 1) mocks.isTerminalScrollRestoreInProgress.mockImplementation(() => false) }) @@ -256,6 +260,40 @@ describe('useTerminalScrollVisibilityMemory', () => { ) }) + it('captures the pending restore target while a visibility restore is settling', () => { + const terminal = { + onScroll: vi.fn(() => ({ dispose: vi.fn() })) + } + const manager = { + getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }]) + } + const pendingRestoreState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 150, + baseY: 154 + } + mocks.getPendingScrollRestoreState.mockReturnValue(pendingRestoreState) + mocks.captureScrollState.mockReturnValue({ + bufferType: 'normal', + wasAtBottom: false, + viewportY: 56, + baseY: 154 + }) + + beginHookRender() + const visibilityMemory = useTerminalScrollVisibilityMemory({ + managerRef: { current: manager as never }, + isVisibleRef: { current: true }, + visibleResumeCompleteRef: { current: true }, + paneCount: 1 + }) + + expect(visibilityMemory.captureViewportPositions(false).get('leaf-1' as never)).toBe( + pendingRestoreState + ) + }) + it('does not reuse a remembered snapshot when a pane id gets a new leaf', () => { const terminal = { onScroll: vi.fn(() => ({ dispose: vi.fn() })) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts index f3e43115428..e7a897188cc 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts @@ -4,6 +4,7 @@ import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-sch import { cancelDeferredScrollRestore, captureScrollState, + getPendingScrollRestoreState, getTerminalOutputEpoch, isTerminalScrollRestoreInProgress } from '@/lib/pane-manager/pane-scroll' @@ -84,7 +85,8 @@ export function useTerminalScrollVisibilityMemory({ if (useRememberedSnapshots && remembered) { return [pane.leafId, remembered.scrollState] as const } - const state = captureScrollState(pane.terminal) + const state = + getPendingScrollRestoreState(pane.terminal) ?? captureScrollState(pane.terminal) const stableState = remembered && isTransientTerminalEdgeSnap(state, remembered.scrollState) ? remembered.scrollState diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index 3aaf00668c9..dbde30c53f4 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -37,6 +37,10 @@ export function isTerminalScrollRestoreInProgress(terminal: Terminal): boolean { return (terminalScrollRestoreDepths.get(terminal) ?? 0) > 0 } +export function getPendingScrollRestoreState(terminal: Terminal): ScrollState | null { + return deferredScrollRestores.get(terminal)?.state ?? null +} + export function cancelDeferredScrollRestore(terminal: Terminal): void { const pending = deferredScrollRestores.get(terminal) if (!pending) {