hermes-agent/apps/desktop/src/store/thread-scroll.ts
brooklyn! bbf020e709
feat(desktop): follow streaming output at bottom + jump-to-bottom button (#45263)
Strict sticky-bottom autoscroll for the chat thread: while the viewport is
parked at the bottom, the tail follows content growth (streaming tokens, late
measurement, Shiki re-highlight) via a useLayoutEffect keyed on the
virtualizer's own size signal, pinned in the same pre-paint pass as its
scrollToFn so the two never rubber-band. The gate is a single boolean — one
upward pixel (scroll/wheel/touch) disarms follow until the user returns to the
bottom.

Adds a floating jump-to-bottom control that appears once scrolled ~10px away
(above the dim threshold so a sub-pixel settle never flashes it), positioned
above the composer with respect to the status stack, with a subtle
scale + slide in/out animation that honours prefers-reduced-motion. The button
bridges to the virtualizer's re-arm + pin path through a small nanostore
emitter.

Supersedes #43624.
2026-06-12 23:00:11 +00:00

35 lines
1.3 KiB
TypeScript

import { atom, type WritableAtom } from 'nanostores'
// `$threadScrolledUp` flips the instant the viewport leaves the bottom (dims the
// composer / status stack). `$threadJumpButtonVisible` trips a little further up
// (~10px) so the floating jump control only shows once meaningfully away.
export const $threadScrolledUp = atom(false)
export const $threadJumpButtonVisible = atom(false)
// Skip no-op writes so subscribers don't churn on every scroll tick.
const setter = (target: WritableAtom<boolean>) => (value: boolean) => {
if (target.get() !== value) {
target.set(value)
}
}
export const setThreadScrolledUp = setter($threadScrolledUp)
export const setThreadJumpButtonVisible = setter($threadJumpButtonVisible)
export const resetThreadScroll = () => {
setThreadScrolledUp(false)
setThreadJumpButtonVisible(false)
}
// Cross-component bridge: the jump button lives by the composer, the re-arm +
// pin machinery lives in the virtualizer. The virtualizer registers a handler;
// the button fires it. Mirrors the composer focus/insert emitter pattern.
const handlers = new Set<() => void>()
export const onScrollToBottomRequest = (handler: () => void) => {
handlers.add(handler)
return () => void handlers.delete(handler)
}
export const requestScrollToBottom = () => handlers.forEach(handler => handler())