Files
hermes-agent/apps/desktop/src/store/thread-scroll.ts
T
David Metcalfe c06caad8f7 fix(desktop): preserve per-session chat scroll position
Rework per hermes-sweeper review (keep_open, salvageability=medium):

- Capture moves out of the render body into the session-switch layout-effect
  cleanup, so only committed switches persist state (no uncommitted render
  can write localStorage).
- Live state is fed by both scroll events and a ResizeObserver on the
  content element, so distance-from-bottom stays fresh under async
  relayout that changes height without a scroll event (the staleness gap
  #70478's own review threads flagged).
- State is distance-from-bottom (or sticky-bottom), not raw scrollTop:
  the render-budget backfill prepends older turns and main anchors by
  distance-from-bottom, so an absolute offset no longer identifies the
  same reading location after the height change.
- Restore integrates with main's hasGroups/settle/anchor lifecycle: the
  settle loop re-applies the remembered target, defers on a clamped offset
  (content still arriving), hands back locked only for sticky-bottom, and
  leaves mid-read sessions escaped at their offset.
- anchorBeforePrepend no longer records 0 mid-load, which would clobber a
  restored offset once the backfill lands; the settle loop owns the target
  every frame until settled.
- Storage is scoped per profile with the session.ts .profile.<encoded>
  key pattern (no cross-profile bleed, #67709 pattern) and LRU-capped at
  120 sessions per profile.
- Regression tests: state classification, target math, profile isolation,
  LRU eviction, corrupt/invalid payload handling.

Related to #45562 (partial; no automatic closure)

(cherry picked from commit 1d79bef93f63d7b190b471bf652b5109b4288a8e)
2026-09-07 06:14:35 -07:00

269 lines
9.5 KiB
TypeScript

import { atom, type WritableAtom } from 'nanostores'
import { readKey, writeKey } from '@/lib/storage'
import { $activeProfile, normalizeProfileKey } from '@/store/profile'
// "Is the thread parked at the bottom" is owned by use-stick-to-bottom inside
// ThreadMessageList (the scroll container). That state lives only in that
// subtree, so ThreadMessageList mirrors it into these atoms for the composer,
// status stack, and floating jump button — all of which render OUTSIDE the thread.
//
// `$threadScrolledUp` dims the composer / status stack; `$threadJumpButtonVisible`
// shows the floating jump control. Both track `!isAtBottom` today, but stay
// separate so their thresholds can diverge again without touching consumers.
//
// Keep-alive tabs stay mounted with a real layout box, so only the on-screen
// pane may publish or reset this composer-facing mirror. Jump-to-bottom
// requests are keyed by session so a click (or an input-request snap) cannot
// scroll every mounted transcript.
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)
}
}
const setScrolledUp = setter($threadScrolledUp)
const setJumpButtonVisible = setter($threadJumpButtonVisible)
export const setThreadAtBottom = (isAtBottom: boolean) => {
setScrolledUp(!isAtBottom)
setJumpButtonVisible(!isAtBottom)
}
export const resetThreadScroll = () => setThreadAtBottom(true)
export const publishThreadAtBottom = (isAtBottom: boolean, publisher: { paneVisible: boolean }): void => {
if (!publisher.paneVisible) {
return
}
setThreadAtBottom(isAtBottom)
}
export const resetPublishedThreadScroll = (publisher: { paneVisible: boolean }): void => {
if (!publisher.paneVisible) {
return
}
resetThreadScroll()
}
// Cross-component bridge: the jump button lives by the composer, the viewport's
// `scrollToBottom` lives inside the thread. The bridge registers a handler; the
// button fires it. Mirrors the composer focus/insert emitter pattern.
const handlers = new Map<string | null, Set<() => void>>()
export const onScrollToBottomRequest = (handler: () => void, sessionId: string | null = null) => {
const scoped = handlers.get(sessionId) ?? new Set<() => void>()
scoped.add(handler)
handlers.set(sessionId, scoped)
return () => {
scoped.delete(handler)
if (scoped.size === 0) {
handlers.delete(sessionId)
}
}
}
export const requestScrollToBottom = (sessionId: string | null = null) => {
handlers.get(sessionId)?.forEach(handler => handler())
}
// Inline edit grows a sticky human bubble. Fire on pointerdown so the viewport
// escapes stick-to-bottom before focus/layout; close clears the edit flag when
// the inline composer unmounts.
const editOpenHandlers = new Set<() => void>()
const editCloseHandlers = new Set<() => void>()
export const onThreadEditOpen = (handler: () => void) => {
editOpenHandlers.add(handler)
return () => void editOpenHandlers.delete(handler)
}
export const notifyThreadEditOpen = () => editOpenHandlers.forEach(handler => handler())
export const onThreadEditClose = (handler: () => void) => {
editCloseHandlers.add(handler)
return () => void editCloseHandlers.delete(handler)
}
export const notifyThreadEditClose = () => editCloseHandlers.forEach(handler => handler())
// ── Per-session scroll position persistence ──────────────────────────────────
// When the user scrolls up to read history, their distance-from-bottom is
// saved keyed by sessionKey and profile. On return, the session-switch settle
// loop restores it instead of pinning to the bottom, so the reading position
// survives session switches. Offsets are stored as distance-from-bottom, not
// scrollTop: the render-budget backfill prepends older turns and the switch
// relayout reshapes content above the on-screen rows, and bottom-anchored math
// keeps the restored view steady under that churn — the same reason the
// "Show earlier" flow in list.tsx restores from the bottom edge.
export type ThreadScrollState = { kind: 'bottom' } | { fromBottom: number; kind: 'offset' }
export const THREAD_SCROLL_BOTTOM: ThreadScrollState = { kind: 'bottom' }
// Within this many pixels of the bottom edge counts as "parked at the bottom".
// Deliberately tight: use-stick-to-bottom's own near-bottom band re-locks lazy
// scrollers anyway, and recording a small real offset as `bottom` would yank a
// reader who stopped just shy of the edge.
export const THREAD_SCROLL_STICKY_THRESHOLD_PX = 8
export type ThreadScrollMetrics = {
clientHeight: number
scrollHeight: number
scrollTop: number
}
export function threadScrollDistanceFromBottom(metrics: ThreadScrollMetrics): number {
return Math.max(0, metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight)
}
/** Classify live metrics as sticky-bottom or an exact reading offset. */
export function threadScrollStateFromMetrics(
metrics: ThreadScrollMetrics,
threshold = THREAD_SCROLL_STICKY_THRESHOLD_PX
): ThreadScrollState {
const fromBottom = threadScrollDistanceFromBottom(metrics)
return fromBottom <= threshold ? THREAD_SCROLL_BOTTOM : { fromBottom, kind: 'offset' }
}
/** The scrollTop that re-applies `state` at the current content height. */
export function threadScrollTargetTop(
state: ThreadScrollState,
metrics: Pick<ThreadScrollMetrics, 'clientHeight' | 'scrollHeight'>
): number {
const max = Math.max(0, metrics.scrollHeight - metrics.clientHeight)
return state.kind === 'bottom' ? max : Math.max(0, max - state.fromBottom)
}
// Storage is scoped per profile with the same `.profile.<encoded>` suffix the
// app's other persisted session state uses (session.ts profileNavigationKey),
// so two profiles can never read or evict each other's reading positions.
const SCROLL_POS_KEY_BASE = 'hermes.desktop.threadScroll.v1'
export function threadScrollStorageKey(profile: string): string {
return `${SCROLL_POS_KEY_BASE}.profile.${encodeURIComponent(normalizeProfileKey(profile))}`
}
// Bounded so a marathon runtime that touches hundreds of sessions doesn't grow
// the map forever. JS object insertion order gives LRU eviction — saving
// delete-and-re-adds the key, so the front is always the least-recently-used.
export const THREAD_SCROLL_MEMORY_LIMIT = 120
function isValidState(value: unknown): value is ThreadScrollState {
if (!value || typeof value !== 'object') {
return false
}
const record = value as Record<string, unknown>
if (record.kind === 'bottom') {
return true
}
return record.kind === 'offset' && typeof record.fromBottom === 'number' && Number.isFinite(record.fromBottom)
}
function loadPositions(profile: string): Record<string, ThreadScrollState> {
const raw = readKey(threadScrollStorageKey(profile))
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>).filter((entry): entry is [string, ThreadScrollState] =>
isValidState(entry[1])
)
)
} catch {
return {}
}
}
function persistPositions(profile: string, positions: Record<string, ThreadScrollState>) {
const keys = Object.keys(positions)
while (keys.length > THREAD_SCROLL_MEMORY_LIMIT) {
delete positions[keys[0]!]
keys.shift()
}
writeKey(threadScrollStorageKey(profile), keys.length === 0 ? null : JSON.stringify(positions))
}
export function getThreadScrollPosition(sessionKey: string): ThreadScrollState | undefined {
return loadPositions($activeProfile.get())[sessionKey]
}
export function saveThreadScrollPosition(sessionKey: string, state: ThreadScrollState) {
const profile = $activeProfile.get()
const positions = loadPositions(profile)
// Delete then re-add to track recency (insertion order = LRU anchor).
delete positions[sessionKey]
positions[sessionKey] = state
persistPositions(profile, positions)
}
export function clearThreadScrollPosition(sessionKey: string) {
const profile = $activeProfile.get()
const positions = loadPositions(profile)
if (positions[sessionKey] === undefined) {
return
}
delete positions[sessionKey]
persistPositions(profile, positions)
}
/**
* The restore/record gate for the session-switch settle loop. Pure so the
* warm/cold switch lifecycle is testable without a DOM:
*
* - cold (no transcript yet): forget any in-flight restore, do not record —
* an empty-transcript instance holds the PREVIOUS session's live state and
* must not file it under the new key.
* - same key, already settled: the restore is done; keep recording only.
* - same key, still settling: a dep identity change re-ran the effect
* mid-loop — re-arm the restore instead of stranding the viewport.
* - anything else (first content for this key, or a key change): restore.
*/
export type ThreadScrollRestorePlan = { cold: boolean; gate: string | null | undefined; restore: boolean }
export function planThreadScrollRestore(
prevGate: string | null | undefined,
sessionKey: string | null | undefined,
hasGroups: boolean,
settled: boolean
): ThreadScrollRestorePlan {
if (!hasGroups) {
return { cold: true, gate: null, restore: false }
}
if (prevGate === sessionKey && settled) {
return { cold: false, gate: sessionKey, restore: false }
}
return { cold: false, gate: sessionKey, restore: true }
}