Preserve pending terminal restore on fast hide

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-24 14:20:18 -07:00
co-authored by Orca
parent 4ef87c12e0
commit 46fd33b8cf
5 changed files with 56 additions and 4 deletions
@@ -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 =
@@ -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
@@ -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() }))
@@ -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
@@ -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) {