Key pending follow-output by terminal leaf

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-24 13:42:22 -07:00
co-authored by Orca
parent c4c8ba022c
commit 8b6a22632a
4 changed files with 93 additions and 13 deletions
@@ -92,7 +92,7 @@ describe('handleFocusTerminalPaneDetail', () => {
)
expect(manager.setActivePane).toHaveBeenCalledWith(7, { focus: true })
expect(scrollToBottomIfOutputSinceLastView).toHaveBeenCalledWith(7)
expect(scrollToBottomIfOutputSinceLastView).toHaveBeenCalledWith(LEAF_ID)
})
it('does not focus, flash, or ack when the numeric pane no longer owns the leaf', () => {
@@ -14,7 +14,7 @@ type FocusTerminalPaneEventDeps = {
manager: FocusTerminalPaneManager | null
acknowledgeAgents: (paneKeys: string[]) => void
surfaceStaleAgentRow: (tabId: string, leafId: string) => void
scrollToBottomIfOutputSinceLastView?: (paneId: number) => void
scrollToBottomIfOutputSinceLastView?: (leafId: ManagedPane['leafId']) => void
}
export function handleFocusTerminalPaneDetail(
@@ -48,7 +48,7 @@ export function handleFocusTerminalPaneDetail(
}
manager.setActivePane(resolution.numericPaneId, { focus: true })
if (detail.scrollToBottomIfOutputSinceLastView) {
scrollToBottomIfOutputSinceLastView?.(resolution.numericPaneId)
scrollToBottomIfOutputSinceLastView?.(resolution.leafId)
}
if (detail.flashFocusedPane) {
const pane = manager.getPanes().find((candidate) => candidate.id === resolution.numericPaneId)
@@ -115,7 +115,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
paneCount: 1
})
visibilityMemory.scheduleFollowOutputIfNeeded(1)
visibilityMemory.scheduleFollowOutputIfNeeded('leaf-1' as never)
animationFrames.shift()?.(16)
animationFrames.shift()?.(32)
@@ -145,7 +145,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
paneCount: 1
})
visibilityMemory.scheduleFollowOutputIfNeeded(1)
visibilityMemory.scheduleFollowOutputIfNeeded('leaf-1' as never)
runEffectCleanups()
expect(cancelAnimationFrame).toHaveBeenCalledWith(7)
@@ -243,4 +243,84 @@ describe('useTerminalScrollVisibilityMemory', () => {
)
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
})
it('does not apply pending follow-output to a reused pane id with a different leaf', () => {
const oldTerminal = {
onScroll: vi.fn(() => ({ dispose: vi.fn() })),
scrollToBottom: vi.fn()
}
const newTerminal = {
onScroll: vi.fn(() => ({ dispose: vi.fn() })),
scrollToBottom: vi.fn()
}
let panes = [{ id: 1, leafId: 'old-leaf', terminal: oldTerminal }]
const manager = {
getPanes: vi.fn(() => panes)
}
const animationFrames: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
})
beginHookRender()
const visibilityMemory = useTerminalScrollVisibilityMemory({
managerRef: { current: manager as never },
isVisibleRef: { current: true },
visibleResumeCompleteRef: { current: true },
paneCount: 1
})
visibilityMemory.scheduleFollowOutputIfNeeded('old-leaf' as never)
panes = [{ id: 1, leafId: 'new-leaf', terminal: newTerminal }]
animationFrames.shift()?.(16)
animationFrames.shift()?.(32)
expect(oldTerminal.scrollToBottom).not.toHaveBeenCalled()
expect(newTerminal.scrollToBottom).not.toHaveBeenCalled()
expect(mocks.flushTerminalOutput).not.toHaveBeenCalled()
})
it('does not follow output when the remembered visible position was not at bottom', () => {
const onScrollListeners: (() => void)[] = []
const terminal = {
onScroll: vi.fn((listener: () => void) => {
onScrollListeners.push(listener)
return { dispose: vi.fn() }
}),
scrollToBottom: vi.fn()
}
const manager = {
getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }])
}
const animationFrames: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
})
mocks.captureScrollState.mockReturnValue({
bufferType: 'normal',
wasAtBottom: false,
viewportY: 42,
baseY: 100
})
beginHookRender()
const visibilityMemory = useTerminalScrollVisibilityMemory({
managerRef: { current: manager as never },
isVisibleRef: { current: true },
visibleResumeCompleteRef: { current: true },
paneCount: 1
})
onScrollListeners[0]?.()
visibilityMemory.scheduleFollowOutputIfNeeded('leaf-1' as never)
animationFrames.shift()?.(16)
animationFrames.shift()?.(32)
expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, {
maxChars: 256 * 1024
})
expect(terminal.scrollToBottom).not.toHaveBeenCalled()
})
})
@@ -28,7 +28,7 @@ type TerminalScrollVisibilityMemory = {
) => Map<ManagedPane['leafId'], ScrollState>
withSuppressedScrollTracking: (callback: () => void) => void
applyPendingFollowOutputRequests: () => boolean
scheduleFollowOutputIfNeeded: (paneId: number) => void
scheduleFollowOutputIfNeeded: (leafId: ManagedPane['leafId']) => void
}
const FOLLOW_OUTPUT_FLUSH_CHARS = 256 * 1024
@@ -44,7 +44,7 @@ export function useTerminalScrollVisibilityMemory({
)
const scrollDisposablesRef = useRef<Map<ManagedPane['leafId'], IDisposable>>(new Map())
const suppressScrollTrackingRef = useRef(false)
const pendingFollowOutputPaneIdsRef = useRef<Set<number>>(new Set())
const pendingFollowOutputLeafIdsRef = useRef<Set<ManagedPane['leafId']>>(new Set())
const followOutputFrameIdsRef = useRef<number[]>([])
const captureVisibleScrollSnapshot = useCallback(
@@ -98,7 +98,7 @@ export function useTerminalScrollVisibilityMemory({
}, [])
const applyPendingFollowOutputRequests = useCallback((): boolean => {
const pending = pendingFollowOutputPaneIdsRef.current
const pending = pendingFollowOutputLeafIdsRef.current
if (pending.size === 0) {
return false
}
@@ -111,7 +111,7 @@ export function useTerminalScrollVisibilityMemory({
}
let didScroll = false
for (const pane of manager.getPanes()) {
if (!pending.has(pane.id)) {
if (!pending.has(pane.leafId)) {
continue
}
const previous = visibleScrollSnapshotsRef.current.get(pane.leafId)
@@ -121,13 +121,13 @@ export function useTerminalScrollVisibilityMemory({
flushTerminalOutput(pane.terminal, { maxChars: FOLLOW_OUTPUT_FLUSH_CHARS })
const currentEpoch = getTerminalOutputEpoch(pane.terminal)
const hasNewOutput = previous ? currentEpoch > previous.outputEpoch : currentEpoch > 0
if (hasNewOutput) {
if (hasNewOutput && (previous?.scrollState.wasAtBottom ?? true)) {
cancelDeferredScrollRestore(pane.terminal)
pane.terminal.scrollToBottom()
rememberVisibleScrollSnapshot(pane.leafId, pane.terminal)
didScroll = true
}
pending.delete(pane.id)
pending.delete(pane.leafId)
}
return didScroll
}, [isVisibleRef, managerRef, rememberVisibleScrollSnapshot, visibleResumeCompleteRef])
@@ -140,8 +140,8 @@ export function useTerminalScrollVisibilityMemory({
}, [])
const scheduleFollowOutputIfNeeded = useCallback(
(paneId: number): void => {
pendingFollowOutputPaneIdsRef.current.add(paneId)
(leafId: ManagedPane['leafId']): void => {
pendingFollowOutputLeafIdsRef.current.add(leafId)
if (followOutputFrameIdsRef.current.length > 0) {
return
}