import { randomUUID } from 'crypto' import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, } from 'react' import { createHistoryAuthCtx, fetchLatestEvents, fetchOlderEvents, type HistoryAuthCtx, type HistoryPage, } from '../assistant/sessionHistory.js' import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js' import { convertSDKMessage } from '../remote/sdkMessageAdapter.js' import type { Message, SystemInformationalMessage } from '../types/message.js' import { logForDebugging } from '../utils/debug.js' type Props = { /** Gated on viewerOnly — non-viewer sessions have no remote history to page. */ config: RemoteSessionConfig | undefined setMessages: React.Dispatch> scrollRef: RefObject /** Called after prepend from the layout effect with message count + height * delta. Lets useUnseenDivider shift dividerIndex + dividerYRef. */ onPrepend?: (indexDelta: number, heightDelta: number) => void } type Result = { /** Trigger for ScrollKeybindingHandler's onScroll composition. */ maybeLoadOlder: (handle: ScrollBoxHandle) => void } /** Fire loadOlder when scrolled within this many rows of the top. */ const PREFETCH_THRESHOLD_ROWS = 40 /** Max chained page loads to fill the viewport on mount. Bounds the loop if * events convert to zero visible messages (everything filtered). */ const MAX_FILL_PAGES = 10 const SENTINEL_LOADING = 'loading older messages…' const SENTINEL_LOADING_FAILED = 'failed to load older messages — scroll up to retry' const SENTINEL_START = 'start of session' /** Convert a HistoryPage to REPL Message[] using the same opts as viewer mode. */ function pageToMessages(page: HistoryPage): Message[] { const out: Message[] = [] for (const ev of page.events) { const c = convertSDKMessage(ev, { convertUserTextMessages: true, convertToolResults: true, }) if (c.type === 'message') out.push(c.message) } return out } /** * Lazy-load `claude assistant` history on scroll-up. * * On mount: fetch newest page via anchor_to_latest, prepend to messages. * On scroll-up near top: fetch next-older page via before_id, prepend with * scroll anchoring (viewport stays put). * * No-op unless config.viewerOnly. REPL only calls this hook inside a * feature('KAIROS') gate, so build-time elimination is handled there. */ export function useAssistantHistory({ config, setMessages, scrollRef, onPrepend, }: Props): Result { const enabled = config?.viewerOnly === true // Cursor state: ref-only (no re-render on cursor change). `null` = no // older pages. `undefined` = initial page not fetched yet. const cursorRef = useRef(undefined) const ctxRef = useRef(null) const inflightRef = useRef(false) // Scroll-anchor: snapshot height + prepended count before setMessages; // compensate in useLayoutEffect after React commits. getFreshScrollHeight // reads Yoga directly so the value is correct post-commit. const anchorRef = useRef<{ beforeHeight: number; count: number } | null>(null) // Fill-viewport chaining: after the initial page commits, if content doesn't // fill the viewport yet, load another page. Self-chains via the layout effect // until filled or the budget runs out. Budget set once on initial load; user // scroll-ups don't need it (maybeLoadOlder re-fires on next wheel event). const fillBudgetRef = useRef(0) // Stable sentinel UUID — reused across swaps so virtual-scroll treats it // as one item (text-only mutation, not remove+insert). const sentinelUuidRef = useRef(randomUUID()) function mkSentinel(text: string): SystemInformationalMessage { return { type: 'system', subtype: 'informational', content: text, isMeta: false, timestamp: new Date().toISOString(), uuid: sentinelUuidRef.current, level: 'info', } } /** Prepend a page at the front, with scroll-anchor snapshot for non-initial. * Replaces the sentinel (always at index 0 when present) in-place. */ const prepend = useCallback( (page: HistoryPage, isInitial: boolean) => { const msgs = pageToMessages(page) cursorRef.current = page.hasMore ? page.firstId : null if (!isInitial) { const s = scrollRef.current anchorRef.current = s ? { beforeHeight: s.getFreshScrollHeight(), count: msgs.length } : null } const sentinel = page.hasMore ? null : mkSentinel(SENTINEL_START) setMessages(prev => { // Drop existing sentinel (index 0, known stable UUID — O(1)). const base = prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev return sentinel ? [sentinel, ...msgs, ...base] : [...msgs, ...base] }) logForDebugging( `[useAssistantHistory] ${isInitial ? 'initial' : 'older'} page: ${msgs.length} msgs (raw ${page.events.length}), hasMore=${page.hasMore}`, ) }, // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollRef is a stable ref; mkSentinel reads refs only [setMessages], ) // Initial fetch on mount — best-effort. useEffect(() => { if (!enabled || !config) return let cancelled = false void (async () => { const ctx = await createHistoryAuthCtx(config.sessionId).catch(() => null) if (!ctx || cancelled) return ctxRef.current = ctx const page = await fetchLatestEvents(ctx) if (cancelled || !page) return fillBudgetRef.current = MAX_FILL_PAGES prepend(page, true) })() return () => { cancelled = true } // config identity is stable (created once in main.tsx, never recreated) // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled]) const loadOlder = useCallback(async () => { if (!enabled || inflightRef.current) return const cursor = cursorRef.current const ctx = ctxRef.current if (!cursor || !ctx) return // null=exhausted, undefined=initial pending inflightRef.current = true // Swap sentinel to "loading…" — O(1) slice since sentinel is at index 0. setMessages(prev => { const base = prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev return [mkSentinel(SENTINEL_LOADING), ...base] }) try { const page = await fetchOlderEvents(ctx, cursor) if (!page) { // Fetch failed — revert sentinel back to "start" placeholder so the user // can retry on next scroll-up. Cursor is preserved (not nulled out). setMessages(prev => { const base = prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev return [mkSentinel(SENTINEL_LOADING_FAILED), ...base] }) return } prepend(page, false) } finally { inflightRef.current = false } // eslint-disable-next-line react-hooks/exhaustive-deps -- mkSentinel reads refs only }, [enabled, prepend, setMessages]) // Scroll-anchor compensation — after React commits the prepended items, // shift scrollTop by the height delta so the viewport stays put. Also // fire onPrepend here (not in prepend()) so dividerIndex + baseline ref // are shifted with the ACTUAL height delta, not an estimate. // No deps: runs every render; cheap no-op when anchorRef is null. useLayoutEffect(() => { const anchor = anchorRef.current if (anchor === null) return anchorRef.current = null const s = scrollRef.current if (!s || s.isSticky()) return // sticky = pinned bottom; prepend is invisible const delta = s.getFreshScrollHeight() - anchor.beforeHeight if (delta > 0) s.scrollBy(delta) onPrepend?.(anchor.count, delta) }) // Fill-viewport chain: after paint, if content doesn't exceed the viewport, // load another page. Runs as useEffect (not layout effect) so Ink has // painted and scrollViewportHeight is populated. Self-chains via next // render's effect; budget caps the chain. // // The ScrollBox content wrapper has flexGrow:1 flexShrink:0 — it's clamped // to ≥ viewport. So `content < viewport` is never true; `<=` detects "no // overflow yet" correctly. Stops once there's at least something to scroll. useEffect(() => { if ( fillBudgetRef.current <= 0 || !cursorRef.current || inflightRef.current ) { return } const s = scrollRef.current if (!s) return const contentH = s.getFreshScrollHeight() const viewH = s.getViewportHeight() logForDebugging( `[useAssistantHistory] fill-check: content=${contentH} viewport=${viewH} budget=${fillBudgetRef.current}`, ) if (contentH <= viewH) { fillBudgetRef.current-- void loadOlder() } else { fillBudgetRef.current = 0 } }) // Trigger wrapper for onScroll composition in REPL. const maybeLoadOlder = useCallback( (handle: ScrollBoxHandle) => { if (handle.getScrollTop() < PREFETCH_THRESHOLD_ROWS) void loadOlder() }, [loadOlder], ) return { maybeLoadOlder } }