hermes-agent/ui-tui/src/lib/viewportStore.ts
Brooklyn Nicholson ce2cc7302e fix(tui): stabilize sticky prompt tracking
Keep the latest prompt sticky while the viewport is in live assistant output beyond history, and clear stale sticky state at the real bottom using fresh scroll height.
2026-04-28 22:10:40 -05:00

74 lines
2.1 KiB
TypeScript

import type { ScrollBoxHandle } from '@hermes/ink'
import type { RefObject } from 'react'
import { useCallback, useMemo, useSyncExternalStore } from 'react'
export interface ViewportSnapshot {
atBottom: boolean
bottom: number
pending: number
scrollHeight: number
top: number
viewportHeight: number
}
const EMPTY: ViewportSnapshot = {
atBottom: true,
bottom: 0,
pending: 0,
scrollHeight: 0,
top: 0,
viewportHeight: 0
}
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
if (!s) {
return EMPTY
}
const pending = s.getPendingDelta()
const top = Math.max(0, s.getScrollTop() + pending)
const viewportHeight = Math.max(0, s.getViewportHeight())
const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight())
let scrollHeight = cachedScrollHeight
const bottom = top + viewportHeight
let atBottom = s.isSticky() || bottom >= scrollHeight - 2
if (!atBottom) {
scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight)
atBottom = s.isSticky() || bottom >= scrollHeight - 2
}
return {
atBottom,
bottom,
pending,
scrollHeight,
top,
viewportHeight
}
}
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 useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => viewportSnapshotKey(getViewportSnapshot(scrollRef.current)),
() => viewportSnapshotKey(EMPTY)
)
return useMemo(() => {
const [atBottom = '1', top = '0', viewportHeight = '0', scrollHeight = '0', pending = '0'] = key.split(':')
return {
atBottom: atBottom === '1',
bottom: Number(top) + Number(viewportHeight),
pending: Number(pending),
scrollHeight: Number(scrollHeight),
top: Number(top),
viewportHeight: Number(viewportHeight)
}
}, [key])
}