From 8b6a22632a46e9ff91f11848fc7c6656da3bc90f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 24 Jun 2026 13:42:22 -0700 Subject: [PATCH] Key pending follow-output by terminal leaf Co-authored-by: Orca --- .../focus-terminal-pane-event.test.ts | 2 +- .../focus-terminal-pane-event.ts | 4 +- ...-terminal-scroll-visibility-memory.test.ts | 84 ++++++++++++++++++- .../use-terminal-scroll-visibility-memory.ts | 16 ++-- 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts index 43a295a1045..fde64e305df 100644 --- a/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts +++ b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts index 45b9a4f6a83..2b978b18bd7 100644 --- a/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts +++ b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts @@ -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) 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 423613471e0..4f1fe0e06b8 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 @@ -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() + }) }) 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 5e37ee4eb52..7c0ba727def 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 @@ -28,7 +28,7 @@ type TerminalScrollVisibilityMemory = { ) => Map 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>(new Map()) const suppressScrollTrackingRef = useRef(false) - const pendingFollowOutputPaneIdsRef = useRef>(new Set()) + const pendingFollowOutputLeafIdsRef = useRef>(new Set()) const followOutputFrameIdsRef = useRef([]) 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 }