/ hooks / useAssistantHistory.ts
useAssistantHistory.ts
  1  import { randomUUID } from 'crypto'
  2  import {
  3    type RefObject,
  4    useCallback,
  5    useEffect,
  6    useLayoutEffect,
  7    useRef,
  8  } from 'react'
  9  import {
 10    createHistoryAuthCtx,
 11    fetchLatestEvents,
 12    fetchOlderEvents,
 13    type HistoryAuthCtx,
 14    type HistoryPage,
 15  } from '../assistant/sessionHistory.js'
 16  import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'
 17  import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'
 18  import { convertSDKMessage } from '../remote/sdkMessageAdapter.js'
 19  import type { Message, SystemInformationalMessage } from '../types/message.js'
 20  import { logForDebugging } from '../utils/debug.js'
 21  
 22  type 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  
 32  type 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. */
 38  const 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). */
 42  const MAX_FILL_PAGES = 10
 43  
 44  const SENTINEL_LOADING = 'loading older messages…'
 45  const SENTINEL_LOADING_FAILED =
 46    'failed to load older messages — scroll up to retry'
 47  const SENTINEL_START = 'start of session'
 48  
 49  /** Convert a HistoryPage to REPL Message[] using the same opts as viewer mode. */
 50  function 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   */
 72  export 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  }