Key terminal visibility scroll memory by leaf

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-24 13:23:26 -07:00
co-authored by Orca
parent e321f30731
commit c4c8ba022c
3 changed files with 84 additions and 31 deletions
@@ -1,5 +1,5 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
import type { ManagedPane, ScrollState } from '@/lib/pane-manager/pane-manager-types'
import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry'
import {
flushTerminalOutput,
@@ -22,7 +22,9 @@ type ResumeTerminalVisibilityArgs = {
isActive: boolean
wasVisible: boolean
shouldUseLightTabResume: boolean
captureViewportPositions: (useRememberedSnapshots: boolean) => Map<number, ScrollState>
captureViewportPositions: (
useRememberedSnapshots: boolean
) => Map<ManagedPane['leafId'], ScrollState>
withSuppressedScrollTracking: (callback: () => void) => void
}
@@ -32,7 +34,9 @@ type HideTerminalVisibilityArgs = {
wasWorktreeActive: boolean
isWorktreeActive: boolean
hasCompletedVisibleResume: boolean
captureViewportPositions: (useRememberedSnapshots: boolean) => Map<number, ScrollState>
captureViewportPositions: (
useRememberedSnapshots: boolean
) => Map<ManagedPane['leafId'], ScrollState>
}
type HideTerminalVisibilityResult = {
@@ -163,13 +167,14 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
function restoreTerminalViewportPositions(
manager: PaneManager,
viewportPositions: Map<number, ScrollState>
viewportPositions: Map<ManagedPane['leafId'], ScrollState>
): void {
for (const pane of manager.getPanes()) {
const position = viewportPositions.get(pane.id)
const position = viewportPositions.get(pane.leafId)
if (position) {
logTerminalScrollRestore('visibility-restore', {
before: terminalViewportForDebug(pane.terminal),
leafId: pane.leafId,
paneId: pane.id,
saved: terminalScrollStateForDebug(position),
source: 'resumeTerminalVisibility'
@@ -99,7 +99,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
scrollToBottom: vi.fn()
}
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal }])
getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }])
}
const animationFrames: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
@@ -131,7 +131,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
scrollToBottom: vi.fn()
}
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal }])
getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }])
}
const cancelAnimationFrame = vi.fn()
globalThis.requestAnimationFrame = vi.fn(() => 7)
@@ -161,7 +161,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
})
}
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal }])
getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }])
}
const userScrolledState = {
bufferType: 'normal',
@@ -195,7 +195,52 @@ describe('useTerminalScrollVisibilityMemory', () => {
mocks.isTerminalScrollRestoreInProgress.mockReturnValueOnce(true)
listener()
expect(visibilityMemory.captureViewportPositions(true).get(1)).toBe(userScrolledState)
expect(visibilityMemory.captureViewportPositions(true).get('leaf-1' as never)).toBe(
userScrolledState
)
expect(mocks.captureScrollState).toHaveBeenCalledTimes(1)
})
it('does not reuse a remembered snapshot when a pane id gets a new leaf', () => {
const terminal = {
onScroll: vi.fn(() => ({ dispose: vi.fn() }))
}
let panes = [{ id: 1, leafId: 'old-leaf', terminal }]
const manager = {
getPanes: vi.fn(() => panes)
}
const oldBottomState = {
bufferType: 'normal',
wasAtBottom: true,
viewportY: 100,
baseY: 100
}
const newLeafState = {
bufferType: 'normal',
wasAtBottom: false,
viewportY: 42,
baseY: 100
}
let currentState = oldBottomState
mocks.captureScrollState.mockImplementation(() => currentState)
beginHookRender()
const visibilityMemory = useTerminalScrollVisibilityMemory({
managerRef: { current: manager as never },
isVisibleRef: { current: true },
visibleResumeCompleteRef: { current: true },
paneCount: 1
})
expect(visibilityMemory.captureViewportPositions(false).get('old-leaf' as never)).toStrictEqual(
oldBottomState
)
panes = [{ id: 1, leafId: 'new-leaf', terminal }]
currentState = newLeafState
expect(visibilityMemory.captureViewportPositions(true).get('new-leaf' as never)).toStrictEqual(
newLeafState
)
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
})
})
@@ -8,7 +8,7 @@ import {
isTerminalScrollRestoreInProgress
} from '@/lib/pane-manager/pane-scroll'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
import type { ManagedPane, ScrollState } from '@/lib/pane-manager/pane-manager-types'
type VisibleScrollSnapshot = {
scrollState: ScrollState
@@ -23,7 +23,9 @@ type UseTerminalScrollVisibilityMemoryArgs = {
}
type TerminalScrollVisibilityMemory = {
captureViewportPositions: (useRememberedSnapshots: boolean) => Map<number, ScrollState>
captureViewportPositions: (
useRememberedSnapshots: boolean
) => Map<ManagedPane['leafId'], ScrollState>
withSuppressedScrollTracking: (callback: () => void) => void
applyPendingFollowOutputRequests: () => boolean
scheduleFollowOutputIfNeeded: (paneId: number) => void
@@ -37,8 +39,10 @@ export function useTerminalScrollVisibilityMemory({
visibleResumeCompleteRef,
paneCount
}: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory {
const visibleScrollSnapshotsRef = useRef<Map<number, VisibleScrollSnapshot>>(new Map())
const scrollDisposablesRef = useRef<Map<number, IDisposable>>(new Map())
const visibleScrollSnapshotsRef = useRef<Map<ManagedPane['leafId'], VisibleScrollSnapshot>>(
new Map()
)
const scrollDisposablesRef = useRef<Map<ManagedPane['leafId'], IDisposable>>(new Map())
const suppressScrollTrackingRef = useRef(false)
const pendingFollowOutputPaneIdsRef = useRef<Set<number>>(new Set())
const followOutputFrameIdsRef = useRef<number[]>([])
@@ -52,32 +56,32 @@ export function useTerminalScrollVisibilityMemory({
)
const rememberVisibleScrollSnapshot = useCallback(
(paneId: number, terminal: Terminal): void => {
visibleScrollSnapshotsRef.current.set(paneId, captureVisibleScrollSnapshot(terminal))
(leafId: ManagedPane['leafId'], terminal: Terminal): void => {
visibleScrollSnapshotsRef.current.set(leafId, captureVisibleScrollSnapshot(terminal))
},
[captureVisibleScrollSnapshot]
)
const captureViewportPositions = useCallback(
(useRememberedSnapshots: boolean): Map<number, ScrollState> => {
(useRememberedSnapshots: boolean): Map<ManagedPane['leafId'], ScrollState> => {
const manager = managerRef.current
if (!manager) {
return new Map()
}
return new Map(
manager.getPanes().map((pane) => {
const remembered = visibleScrollSnapshotsRef.current.get(pane.id)
const remembered = visibleScrollSnapshotsRef.current.get(pane.leafId)
if (useRememberedSnapshots && remembered) {
return [pane.id, remembered.scrollState] as const
return [pane.leafId, remembered.scrollState] as const
}
const state = captureScrollState(pane.terminal)
if (!useRememberedSnapshots || !remembered) {
visibleScrollSnapshotsRef.current.set(pane.id, {
visibleScrollSnapshotsRef.current.set(pane.leafId, {
scrollState: state,
outputEpoch: getTerminalOutputEpoch(pane.terminal)
})
}
return [pane.id, state] as const
return [pane.leafId, state] as const
})
)
},
@@ -110,7 +114,7 @@ export function useTerminalScrollVisibilityMemory({
if (!pending.has(pane.id)) {
continue
}
const previous = visibleScrollSnapshotsRef.current.get(pane.id)
const previous = visibleScrollSnapshotsRef.current.get(pane.leafId)
// Why: focus/follow can run immediately after a hidden pane becomes
// visible. A bounded flush is enough to observe new output without
// putting the whole hidden PTY backlog back on the interaction path.
@@ -120,7 +124,7 @@ export function useTerminalScrollVisibilityMemory({
if (hasNewOutput) {
cancelDeferredScrollRestore(pane.terminal)
pane.terminal.scrollToBottom()
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
rememberVisibleScrollSnapshot(pane.leafId, pane.terminal)
didScroll = true
}
pending.delete(pane.id)
@@ -167,17 +171,16 @@ export function useTerminalScrollVisibilityMemory({
}
const disposables = scrollDisposablesRef.current
const panes = manager.getPanes()
const livePaneIds = new Set(panes.map((pane) => pane.id))
for (const [paneId, disposable] of disposables) {
if (!livePaneIds.has(paneId)) {
const liveLeafIds = new Set(panes.map((pane) => pane.leafId))
for (const [leafId, disposable] of disposables) {
if (!liveLeafIds.has(leafId)) {
disposable.dispose()
disposables.delete(paneId)
visibleScrollSnapshotsRef.current.delete(paneId)
pendingFollowOutputPaneIdsRef.current.delete(paneId)
disposables.delete(leafId)
visibleScrollSnapshotsRef.current.delete(leafId)
}
}
for (const pane of panes) {
if (disposables.has(pane.id)) {
if (disposables.has(pane.leafId)) {
continue
}
const onScroll = (
@@ -189,7 +192,7 @@ export function useTerminalScrollVisibilityMemory({
continue
}
disposables.set(
pane.id,
pane.leafId,
onScroll.call(pane.terminal, () => {
if (
!isVisibleRef.current ||
@@ -198,7 +201,7 @@ export function useTerminalScrollVisibilityMemory({
) {
return
}
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
rememberVisibleScrollSnapshot(pane.leafId, pane.terminal)
})
)
}