mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Key terminal visibility scroll memory by leaf
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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'
|
||||
|
||||
+49
-4
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+25
-22
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user