fix(tui): steady transcript scrollbar (#20917)

* fix(tui): steady transcript scrollbar

Keep the visible scrollbar tied to committed viewport position while virtual history can still prefetch against pending scroll targets, and preserve drag grab offset synchronously for native-feeling scrollbar drags.

* fix(tui): smooth precision wheel scroll

Replace the opt-scroll throttle with frame-sized coalescing so modifier wheel gestures stay line-precise without stepping.
This commit is contained in:
brooklyn! 2026-05-06 14:50:31 -07:00 committed by GitHub
parent 53a024994a
commit 5ccab51fa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 196 additions and 34 deletions

View file

@ -11,6 +11,12 @@ export interface ViewportSnapshot {
viewportHeight: number
}
export interface ScrollbarSnapshot {
scrollHeight: number
top: number
viewportHeight: number
}
const EMPTY: ViewportSnapshot = {
atBottom: true,
bottom: 0,
@ -20,6 +26,12 @@ const EMPTY: ViewportSnapshot = {
viewportHeight: 0
}
const EMPTY_SCROLLBAR: ScrollbarSnapshot = {
scrollHeight: 0,
top: 0,
viewportHeight: 0
}
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
if (!s) {
return EMPTY
@ -52,6 +64,26 @@ export function viewportSnapshotKey(v: ViewportSnapshot) {
return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}`
}
export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot {
if (!s) {
return EMPTY_SCROLLBAR
}
const viewportHeight = Math.max(0, s.getViewportHeight())
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
const maxTop = Math.max(0, scrollHeight - viewportHeight)
return {
scrollHeight,
top: Math.max(0, Math.min(maxTop, s.getScrollTop())),
viewportHeight
}
}
export function scrollbarSnapshotKey(v: ScrollbarSnapshot) {
return `${v.top}:${v.viewportHeight}:${v.scrollHeight}`
}
export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>
}
}, [key])
}
export function useScrollbarSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ScrollbarSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)),
() => scrollbarSnapshotKey(EMPTY_SCROLLBAR)
)
return useMemo(() => {
const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':')
return {
scrollHeight: Number(scrollHeight),
top: Number(top),
viewportHeight: Number(viewportHeight)
}
}, [key])
}