source dump of claude code
at main 250 lines 9.2 kB view raw
1import { randomUUID } from 'crypto' 2import { 3 type RefObject, 4 useCallback, 5 useEffect, 6 useLayoutEffect, 7 useRef, 8} from 'react' 9import { 10 createHistoryAuthCtx, 11 fetchLatestEvents, 12 fetchOlderEvents, 13 type HistoryAuthCtx, 14 type HistoryPage, 15} from '../assistant/sessionHistory.js' 16import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' 17import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js' 18import { convertSDKMessage } from '../remote/sdkMessageAdapter.js' 19import type { Message, SystemInformationalMessage } from '../types/message.js' 20import { logForDebugging } from '../utils/debug.js' 21 22type Props = { 23 /** Gated on viewerOnly — non-viewer sessions have no remote history to page. */ 24 config: RemoteSessionConfig | undefined 25 setMessages: React.Dispatch<React.SetStateAction<Message[]>> 26 scrollRef: RefObject<ScrollBoxHandle | null> 27 /** Called after prepend from the layout effect with message count + height 28 * delta. Lets useUnseenDivider shift dividerIndex + dividerYRef. */ 29 onPrepend?: (indexDelta: number, heightDelta: number) => void 30} 31 32type Result = { 33 /** Trigger for ScrollKeybindingHandler's onScroll composition. */ 34 maybeLoadOlder: (handle: ScrollBoxHandle) => void 35} 36 37/** Fire loadOlder when scrolled within this many rows of the top. */ 38const PREFETCH_THRESHOLD_ROWS = 40 39 40/** Max chained page loads to fill the viewport on mount. Bounds the loop if 41 * events convert to zero visible messages (everything filtered). */ 42const MAX_FILL_PAGES = 10 43 44const SENTINEL_LOADING = 'loading older messages…' 45const SENTINEL_LOADING_FAILED = 46 'failed to load older messages — scroll up to retry' 47const SENTINEL_START = 'start of session' 48 49/** Convert a HistoryPage to REPL Message[] using the same opts as viewer mode. */ 50function pageToMessages(page: HistoryPage): Message[] { 51 const out: Message[] = [] 52 for (const ev of page.events) { 53 const c = convertSDKMessage(ev, { 54 convertUserTextMessages: true, 55 convertToolResults: true, 56 }) 57 if (c.type === 'message') out.push(c.message) 58 } 59 return out 60} 61 62/** 63 * Lazy-load `claude assistant` history on scroll-up. 64 * 65 * On mount: fetch newest page via anchor_to_latest, prepend to messages. 66 * On scroll-up near top: fetch next-older page via before_id, prepend with 67 * scroll anchoring (viewport stays put). 68 * 69 * No-op unless config.viewerOnly. REPL only calls this hook inside a 70 * feature('KAIROS') gate, so build-time elimination is handled there. 71 */ 72export function useAssistantHistory({ 73 config, 74 setMessages, 75 scrollRef, 76 onPrepend, 77}: Props): Result { 78 const enabled = config?.viewerOnly === true 79 80 // Cursor state: ref-only (no re-render on cursor change). `null` = no 81 // older pages. `undefined` = initial page not fetched yet. 82 const cursorRef = useRef<string | null | undefined>(undefined) 83 const ctxRef = useRef<HistoryAuthCtx | null>(null) 84 const inflightRef = useRef(false) 85 86 // Scroll-anchor: snapshot height + prepended count before setMessages; 87 // compensate in useLayoutEffect after React commits. getFreshScrollHeight 88 // reads Yoga directly so the value is correct post-commit. 89 const anchorRef = useRef<{ beforeHeight: number; count: number } | null>(null) 90 91 // Fill-viewport chaining: after the initial page commits, if content doesn't 92 // fill the viewport yet, load another page. Self-chains via the layout effect 93 // until filled or the budget runs out. Budget set once on initial load; user 94 // scroll-ups don't need it (maybeLoadOlder re-fires on next wheel event). 95 const fillBudgetRef = useRef(0) 96 97 // Stable sentinel UUID — reused across swaps so virtual-scroll treats it 98 // as one item (text-only mutation, not remove+insert). 99 const sentinelUuidRef = useRef(randomUUID()) 100 101 function mkSentinel(text: string): SystemInformationalMessage { 102 return { 103 type: 'system', 104 subtype: 'informational', 105 content: text, 106 isMeta: false, 107 timestamp: new Date().toISOString(), 108 uuid: sentinelUuidRef.current, 109 level: 'info', 110 } 111 } 112 113 /** Prepend a page at the front, with scroll-anchor snapshot for non-initial. 114 * Replaces the sentinel (always at index 0 when present) in-place. */ 115 const prepend = useCallback( 116 (page: HistoryPage, isInitial: boolean) => { 117 const msgs = pageToMessages(page) 118 cursorRef.current = page.hasMore ? page.firstId : null 119 120 if (!isInitial) { 121 const s = scrollRef.current 122 anchorRef.current = s 123 ? { beforeHeight: s.getFreshScrollHeight(), count: msgs.length } 124 : null 125 } 126 127 const sentinel = page.hasMore ? null : mkSentinel(SENTINEL_START) 128 setMessages(prev => { 129 // Drop existing sentinel (index 0, known stable UUID — O(1)). 130 const base = 131 prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev 132 return sentinel ? [sentinel, ...msgs, ...base] : [...msgs, ...base] 133 }) 134 135 logForDebugging( 136 `[useAssistantHistory] ${isInitial ? 'initial' : 'older'} page: ${msgs.length} msgs (raw ${page.events.length}), hasMore=${page.hasMore}`, 137 ) 138 }, 139 // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollRef is a stable ref; mkSentinel reads refs only 140 [setMessages], 141 ) 142 143 // Initial fetch on mount — best-effort. 144 useEffect(() => { 145 if (!enabled || !config) return 146 let cancelled = false 147 void (async () => { 148 const ctx = await createHistoryAuthCtx(config.sessionId).catch(() => null) 149 if (!ctx || cancelled) return 150 ctxRef.current = ctx 151 const page = await fetchLatestEvents(ctx) 152 if (cancelled || !page) return 153 fillBudgetRef.current = MAX_FILL_PAGES 154 prepend(page, true) 155 })() 156 return () => { 157 cancelled = true 158 } 159 // config identity is stable (created once in main.tsx, never recreated) 160 // eslint-disable-next-line react-hooks/exhaustive-deps 161 }, [enabled]) 162 163 const loadOlder = useCallback(async () => { 164 if (!enabled || inflightRef.current) return 165 const cursor = cursorRef.current 166 const ctx = ctxRef.current 167 if (!cursor || !ctx) return // null=exhausted, undefined=initial pending 168 inflightRef.current = true 169 // Swap sentinel to "loading…" — O(1) slice since sentinel is at index 0. 170 setMessages(prev => { 171 const base = 172 prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev 173 return [mkSentinel(SENTINEL_LOADING), ...base] 174 }) 175 try { 176 const page = await fetchOlderEvents(ctx, cursor) 177 if (!page) { 178 // Fetch failed — revert sentinel back to "start" placeholder so the user 179 // can retry on next scroll-up. Cursor is preserved (not nulled out). 180 setMessages(prev => { 181 const base = 182 prev[0]?.uuid === sentinelUuidRef.current ? prev.slice(1) : prev 183 return [mkSentinel(SENTINEL_LOADING_FAILED), ...base] 184 }) 185 return 186 } 187 prepend(page, false) 188 } finally { 189 inflightRef.current = false 190 } 191 // eslint-disable-next-line react-hooks/exhaustive-deps -- mkSentinel reads refs only 192 }, [enabled, prepend, setMessages]) 193 194 // Scroll-anchor compensation — after React commits the prepended items, 195 // shift scrollTop by the height delta so the viewport stays put. Also 196 // fire onPrepend here (not in prepend()) so dividerIndex + baseline ref 197 // are shifted with the ACTUAL height delta, not an estimate. 198 // No deps: runs every render; cheap no-op when anchorRef is null. 199 useLayoutEffect(() => { 200 const anchor = anchorRef.current 201 if (anchor === null) return 202 anchorRef.current = null 203 const s = scrollRef.current 204 if (!s || s.isSticky()) return // sticky = pinned bottom; prepend is invisible 205 const delta = s.getFreshScrollHeight() - anchor.beforeHeight 206 if (delta > 0) s.scrollBy(delta) 207 onPrepend?.(anchor.count, delta) 208 }) 209 210 // Fill-viewport chain: after paint, if content doesn't exceed the viewport, 211 // load another page. Runs as useEffect (not layout effect) so Ink has 212 // painted and scrollViewportHeight is populated. Self-chains via next 213 // render's effect; budget caps the chain. 214 // 215 // The ScrollBox content wrapper has flexGrow:1 flexShrink:0 — it's clamped 216 // to ≥ viewport. So `content < viewport` is never true; `<=` detects "no 217 // overflow yet" correctly. Stops once there's at least something to scroll. 218 useEffect(() => { 219 if ( 220 fillBudgetRef.current <= 0 || 221 !cursorRef.current || 222 inflightRef.current 223 ) { 224 return 225 } 226 const s = scrollRef.current 227 if (!s) return 228 const contentH = s.getFreshScrollHeight() 229 const viewH = s.getViewportHeight() 230 logForDebugging( 231 `[useAssistantHistory] fill-check: content=${contentH} viewport=${viewH} budget=${fillBudgetRef.current}`, 232 ) 233 if (contentH <= viewH) { 234 fillBudgetRef.current-- 235 void loadOlder() 236 } else { 237 fillBudgetRef.current = 0 238 } 239 }) 240 241 // Trigger wrapper for onScroll composition in REPL. 242 const maybeLoadOlder = useCallback( 243 (handle: ScrollBoxHandle) => { 244 if (handle.getScrollTop() < PREFETCH_THRESHOLD_ROWS) void loadOlder() 245 }, 246 [loadOlder], 247 ) 248 249 return { maybeLoadOlder } 250}