Use numeric scroll restore for visibility resume

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-24 14:12:38 -07:00
co-authored by Orca
parent adf8d1ab6a
commit 4ef87c12e0
7 changed files with 44 additions and 13 deletions
@@ -3,6 +3,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager'
type FitPanesOptions = {
debugSource?: string
syncScrollbar?: boolean
useMarkers?: boolean
}
export function fitPanes(manager: PaneManager, options?: FitPanesOptions): void {
@@ -156,12 +156,14 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
if (isActive) {
fitAndFocusPanes(manager, {
debugSource: 'visibility-resume-fit',
syncScrollbar: false
syncScrollbar: false,
useMarkers: false
})
} else {
fitPanes(manager, {
debugSource: 'visibility-resume-fit',
syncScrollbar: false
syncScrollbar: false,
useMarkers: false
})
}
}
@@ -182,7 +184,8 @@ function restoreTerminalViewportPositions(
})
restoreScrollStateAfterLayout(pane.terminal, position, {
debugSource: 'visibility-restore',
syncScrollbar: false
syncScrollbar: false,
useMarkers: false
})
}
}
@@ -531,7 +531,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(manager.resumeRendering).toHaveBeenCalledTimes(1)
expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager, {
debugSource: 'visibility-resume-fit',
syncScrollbar: false
syncScrollbar: false,
useMarkers: false
})
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
})
@@ -623,7 +624,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(manager.suspendRendering).toHaveBeenCalledTimes(1)
expect(mocks.restoreScrollStateAfterLayout).toHaveBeenLastCalledWith(terminalA, preHideState, {
debugSource: 'visibility-restore',
syncScrollbar: false
syncScrollbar: false,
useMarkers: false
})
})
@@ -161,7 +161,11 @@ export class PaneManager {
return Array.from(this.panes.values()).map(toPublicPane)
}
fitAllPanes(options?: { debugSource?: string; syncScrollbar?: boolean }): void {
fitAllPanes(options?: {
debugSource?: string
syncScrollbar?: boolean
useMarkers?: boolean
}): void {
fitAllPanesInternal(this.panes, options)
}
@@ -210,6 +210,23 @@ describe('scroll state', () => {
expect(marker.dispose).toHaveBeenCalledTimes(1)
})
it('can disable marker restore for visibility resume', () => {
const terminal = createTerminal({ viewportY: 10, baseY: 300 })
const marker = createMarker(71)
const state: ScrollState = {
bufferType: 'normal',
wasAtBottom: false,
viewportY: 150,
baseY: 154,
firstVisibleLineMarker: marker
}
restoreScrollStateAfterLayout(terminal, state, { useMarkers: false })
expect(terminal.scrollToLine).toHaveBeenCalledWith(150)
expect(terminal.buffer.active.viewportY).toBe(150)
})
it('reapplies a layout restore after xterm settles asynchronously', () => {
vi.useFakeTimers()
const rafCallbacks: FrameRequestCallback[] = []
@@ -22,6 +22,7 @@ const deferredScrollRestores = new WeakMap<
type RestoreScrollStateOptions = {
debugSource?: string
syncScrollbar?: boolean
useMarkers?: boolean
}
export function recordTerminalOutput(terminal: Terminal): void {
@@ -74,7 +75,7 @@ export function captureScrollState(terminal: Terminal): ScrollState {
export function restoreScrollState(terminal: Terminal, state: ScrollState): void {
cancelDeferredScrollRestore(terminal)
restoreScrollStateNow(terminal, state, { syncScrollbar: true })
restoreScrollStateNow(terminal, state, { syncScrollbar: true, useMarkers: true })
releaseScrollStateMarker(state)
}
@@ -85,6 +86,7 @@ export function restoreScrollStateAfterLayout(
): void {
cancelDeferredScrollRestore(terminal)
const syncScrollbar = options.syncScrollbar ?? true
const useMarkers = options.useMarkers ?? true
const debugSource = options.debugSource
logTerminalScrollRestore('restore-scheduled', {
source: debugSource,
@@ -92,7 +94,7 @@ export function restoreScrollStateAfterLayout(
syncScrollbar,
viewport: terminalViewportForDebug(terminal)
})
restoreScrollStateNow(terminal, state, { debugSource, syncScrollbar })
restoreScrollStateNow(terminal, state, { debugSource, syncScrollbar, useMarkers })
if (typeof requestAnimationFrame !== 'function') {
releaseScrollStateMarker(state)
return
@@ -107,7 +109,7 @@ export function restoreScrollStateAfterLayout(
}
const restore = (): void => {
if (!pending.cancelled) {
restoreScrollStateNow(terminal, state, { debugSource, syncScrollbar })
restoreScrollStateNow(terminal, state, { debugSource, syncScrollbar, useMarkers })
}
}
const cancelPendingRafs = (): void => {
@@ -129,7 +131,7 @@ export function restoreScrollStateAfterLayout(
})
const timeoutId = setTimeout(() => {
if (!pending.cancelled) {
restoreScrollStateNow(terminal, state, { debugSource, syncScrollbar })
restoreScrollStateNow(terminal, state, { debugSource, syncScrollbar, useMarkers })
}
// Why: background tabs can throttle rAF past the timeout. Once the
// authoritative timeout restore has run, stale frame callbacks must not
@@ -146,7 +148,7 @@ export function restoreScrollStateAfterLayout(
function restoreScrollStateNow(
terminal: Terminal,
state: ScrollState,
options: RestoreScrollStateOptions & { syncScrollbar: boolean }
options: RestoreScrollStateOptions & { syncScrollbar: boolean; useMarkers: boolean }
): void {
if (!terminal.element) {
logTerminalScrollRestore('restore-attempt', {
@@ -197,7 +199,7 @@ function restoreScrollStateNow(
: -1
// Why: xterm markers can drift while TUIs rewrite/reflow the same scrollback
// base. Prefer the exact numeric viewport unless the buffer base changed.
const shouldUseMarkerLine = markerLine >= 0 && buf.baseY !== state.baseY
const shouldUseMarkerLine = options.useMarkers && markerLine >= 0 && buf.baseY !== state.baseY
const targetLine = Math.min(shouldUseMarkerLine ? markerLine : state.viewportY, buf.baseY)
// Why: deferred rAF/timeout restores re-invoke this function after xterm
// reflow settles; keep the original viewport and marker alive so later
@@ -30,6 +30,7 @@ type TreeOpsCallbacks = {
type SafeFitOptions = {
debugSource?: string
syncScrollbar?: boolean
useMarkers?: boolean
}
const MIN_PANE_FIT_WIDTH_PX = 48
@@ -110,7 +111,8 @@ export function safeFit(pane: ManagedPane, options: SafeFitOptions = {}): void {
try {
restoreScrollStateAfterLayout(pane.terminal, scrollState, {
debugSource: options.debugSource ?? 'safeFit',
syncScrollbar: options.syncScrollbar
syncScrollbar: options.syncScrollbar,
useMarkers: options.useMarkers
})
} catch {
// Why: xterm can temporarily expose a terminal whose renderer has not