Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
9.2 kB · 250 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251import { 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<React.SetStateAction<Message[]>> scrollRef: RefObject<ScrollBoxHandle | null> /** 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<string | null | undefined>(undefined) const ctxRef = useRef<HistoryAuthCtx | null>(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 }}