| 1 | import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 2 | import { ArrowDown } from "lucide-react"; |
| 3 | import { getTranscriptStore } from "../lib/transcriptStore"; |
| 4 | import type { ControllerLiveStore, HistoryLoadOutcome, HistoryLoadTrigger, Item, LiveStream } from "../lib/useController"; |
| 5 | import type { LocalSubmission } from "../lib/localSubmissionState"; |
| 6 | import { forkTargetForAnswer, type ForkBlockReason, type ForkTargetSetView, type ForkTargetView } from "../lib/forkTargets"; |
| 7 | import type { InvocationMetadataMap } from "../lib/invocationDisplay"; |
| 8 | import { acquireMarkdownWorkerClient, releaseMarkdownWorkerClient } from "../lib/markdownWorkerClient"; |
| 9 | import { ChatSource } from "../lib/chatViewSource"; |
| 10 | import { ChatScrollController } from "../lib/chatScrollController"; |
| 11 | import { ChatContentLoader } from "../lib/chatContentLoader"; |
| 12 | import { ChatMountedOrder } from "../lib/chatMountedOrder"; |
| 13 | import { ChatTurnJump } from "../lib/chatTurnJump"; |
| 14 | import { findLoadedTurn, indexLoadedTurns, type LoadedTurnIndex } from "../lib/chatTurnRail"; |
| 15 | import { getTranscriptOutlineStore } from "../lib/transcriptOutlineStore"; |
| 16 | import { addBreadcrumb } from "../lib/breadcrumbs"; |
| 17 | import { useT } from "../lib/i18n"; |
| 18 | import { InvocationMetadataContext } from "./Message"; |
| 19 | import { MarkdownImageTabContext } from "./MarkdownImageContext"; |
| 20 | import { ChatFileScopeProvider } from "./ChatFileLinkContext"; |
| 21 | import { ChatDetails, ChatNodeList, ChatRunning, type ChatActions } from "./ChatNodes"; |
| 22 | import { Welcome } from "./Welcome"; |
| 23 | import "./ChatTranscript.css"; |
| 24 | const ChatTurnNavigator = lazy(() => import("./ChatTurnNavigator")); |
| 25 | export { NoticeCard } from "./TranscriptCards"; |
| 26 | |
| 27 | export type TranscriptProps = { |
| 28 | items: Item[]; |
| 29 | localSubmissions?: readonly LocalSubmission[]; |
| 30 | localSubmissionSendRevision?: number; |
| 31 | visibleSubmissionHandoffs?: Readonly<Record<string, { submissionId: string }>>; |
| 32 | live?: LiveStream; |
| 33 | liveStore?: ControllerLiveStore; |
| 34 | tabId?: string; |
| 35 | hostId?: string; |
| 36 | geometrySessionKey?: string; |
| 37 | footerHeight?: number; |
| 38 | onPrompt: (displayText: string, submitText?: string) => void; |
| 39 | onFork?: (target: ForkTargetView) => void; |
| 40 | /** Persisted fork boundaries of the shown session; undefined until the first read resolves. */ |
| 41 | forkTargets?: ForkTargetSetView; |
| 42 | /** Non-null replaces every fork entry's own state, e.g. a surface that cannot create a child. */ |
| 43 | forkBlocked?: ForkBlockReason | null; |
| 44 | running?: boolean; |
| 45 | hydrating?: boolean; |
| 46 | hasOlderHistory?: boolean; |
| 47 | hasNewerHistory?: boolean; |
| 48 | historyStartTurn?: number; |
| 49 | historyEndTurn?: number; |
| 50 | /** Total turns the snapshot reports, used to keep the rail area while the |
| 51 | * outline loads without showing it on a brand-new conversation. */ |
| 52 | totalTurns?: number; |
| 53 | loadingOlderHistory?: boolean; |
| 54 | olderHistoryError?: string; |
| 55 | onLoadOlderHistory?: (targetTurn?: number, trigger?: HistoryLoadTrigger) => HistoryLoadOutcome | boolean | Promise<HistoryLoadOutcome | boolean>; |
| 56 | loadingNewerHistory?: boolean; |
| 57 | newerHistoryError?: string; |
| 58 | onLoadNewerHistory?: (latest?: boolean) => HistoryLoadOutcome | boolean | Promise<HistoryLoadOutcome | boolean>; |
| 59 | turnStartAt?: number; |
| 60 | invocationMetadata?: InvocationMetadataMap; |
| 61 | surfaceCommitToken?: string; |
| 62 | onSurfacePaintReady?: (token: string, outcome: "ready" | "degraded") => void; |
| 63 | }; |
| 64 | |
| 65 | |
| 66 | /** Local and remote hosts share this natural-flow presentation adapter. */ |
| 67 | export function Transcript(props: TranscriptProps) { |
| 68 | const sessionKey = props.geometrySessionKey || `tab:${props.tabId ?? "preview"}`; |
| 69 | return <ChatSession key={sessionKey} {...props} sessionKey={sessionKey} />; |
| 70 | } |
| 71 | |
| 72 | function TranscriptConnection({ tabId }: { tabId?: string }) { |
| 73 | const store = getTranscriptStore(); |
| 74 | const subscribe = useCallback((listener: () => void) => tabId ? store.subscribeState(tabId, listener) : () => {}, [store, tabId]); |
| 75 | const snapshot = useCallback(() => tabId ? store.states.get(tabId)?.transcriptConnection : undefined, [store, tabId]); |
| 76 | const status = useSyncExternalStore(subscribe, snapshot, snapshot); |
| 77 | const t = useT(); |
| 78 | return status && status !== "connected" ? <p role="status">{t(status === "syncing" ? "chat.syncing" : "chat.disconnected")}</p> : null; |
| 79 | } |
| 80 | |
| 81 | function ChatSession(props: TranscriptProps & { sessionKey: string }) { |
| 82 | const { sessionKey, tabId, items, live, liveStore, running = false, hydrating = false, |
| 83 | hasOlderHistory = false, hasNewerHistory = false, loadingOlderHistory = false, olderHistoryError, |
| 84 | loadingNewerHistory = false, newerHistoryError, turnStartAt, |
| 85 | onLoadOlderHistory, onLoadNewerHistory, onPrompt, onFork, onSurfacePaintReady, surfaceCommitToken } = props; |
| 86 | const t = useT(); |
| 87 | const [source] = useState(() => new ChatSource(sessionKey)); |
| 88 | const [mounts] = useState(() => new ChatMountedOrder()); |
| 89 | const order = useSyncExternalStore(source.subscribeOrder, source.getOrderSnapshot, source.getOrderSnapshot); |
| 90 | const [scroll] = useState(() => new ChatScrollController(sessionKey)); |
| 91 | const loader = useMemo(() => new ChatContentLoader(tabId), [tabId]); |
| 92 | const scroller = useRef<HTMLDivElement>(null); |
| 93 | const column = useRef<HTMLDivElement>(null); |
| 94 | const trigger = useRef<HTMLElement | null>(null); |
| 95 | const lifetime = useRef(0); |
| 96 | const [details, setDetails] = useState<string>(); |
| 97 | const closeDetails = useCallback(() => { setDetails(undefined); }, []); |
| 98 | const openDetails = useCallback((key: string, element: HTMLElement) => { trigger.current = element; setDetails(key); }, []); |
| 99 | const recover = useCallback((id: string) => onPrompt(t("notice.protocolRecoveryAction"), `/recover-context ${id}`), [onPrompt, t]); |
| 100 | const actions = useMemo<ChatActions>(() => ({ openDetails, recover, |
| 101 | fork: onFork ? { |
| 102 | targetFor: (answerKey) => forkTargetForAnswer(props.forkTargets, answerKey), |
| 103 | loaded: props.forkTargets !== undefined, |
| 104 | verifiable: props.forkTargets?.verifiable ?? false, |
| 105 | blocked: props.forkBlocked ?? null, |
| 106 | create: onFork, |
| 107 | } : undefined }), [openDetails, recover, onFork, props.forkTargets, props.forkBlocked]); |
| 108 | useLayoutEffect(() => { |
| 109 | source.update({ items, live: props.hasNewerHistory ? undefined : liveStore?.getSnapshot(tabId) ?? live, running, hydrating, |
| 110 | localSubmissions: props.hasNewerHistory ? [] : props.localSubmissions, |
| 111 | visibleSubmissionHandoffs: props.visibleSubmissionHandoffs, |
| 112 | hasOlder: hasOlderHistory, loadingOlder: loadingOlderHistory, error: olderHistoryError, |
| 113 | startedAt: turnStartAt, historyStartTurn: props.historyStartTurn }); |
| 114 | }, [source, items, props.localSubmissions, props.visibleSubmissionHandoffs, live, liveStore, tabId, running, hydrating, hasOlderHistory, loadingOlderHistory, olderHistoryError, turnStartAt, props.historyStartTurn, props.hasNewerHistory]); |
| 115 | useEffect(() => liveStore?.subscribe(tabId, () => source.updateLive(props.hasNewerHistory ? undefined : liveStore.getSnapshot(tabId))), [source, liveStore, tabId, props.hasNewerHistory]); |
| 116 | useLayoutEffect(() => { |
| 117 | if (scroller.current && column.current) scroll.attach(scroller.current, column.current); |
| 118 | return () => scroll.dispose(); |
| 119 | }, [scroll]); |
| 120 | useEffect(() => { |
| 121 | loader.activate(); |
| 122 | acquireMarkdownWorkerClient(); |
| 123 | return () => { lifetime.current++; source.dispose(); mounts.dispose(); loader.dispose(); releaseMarkdownWorkerClient(); }; |
| 124 | }, [source, mounts, loader]); |
| 125 | useLayoutEffect(() => { |
| 126 | if (!hydrating) scroll.ready(); |
| 127 | scroll.layout(); |
| 128 | }, [scroll, items, hydrating, props.footerHeight, details]); |
| 129 | useEffect(() => { |
| 130 | if (hydrating || !surfaceCommitToken || (items.length > 0 && order.length === 0)) return; |
| 131 | let paint = 0; |
| 132 | const frame = requestAnimationFrame(() => { paint = requestAnimationFrame(() => onSurfacePaintReady?.(surfaceCommitToken, "ready")); }); |
| 133 | return () => { cancelAnimationFrame(frame); cancelAnimationFrame(paint); }; |
| 134 | }, [hydrating, surfaceCommitToken, onSurfacePaintReady, items.length, order.length]); |
| 135 | const submissionRevision = props.localSubmissionSendRevision ?? 0; |
| 136 | const previousSubmissionRevision = useRef(submissionRevision); |
| 137 | useLayoutEffect(() => { |
| 138 | if (previousSubmissionRevision.current !== submissionRevision && running && !hydrating && !props.hasNewerHistory) scroll.toBottom(); |
| 139 | previousSubmissionRevision.current = submissionRevision; |
| 140 | }, [submissionRevision, running, hydrating, scroll, props.hasNewerHistory]); |
| 141 | const position = useSyncExternalStore(scroll.subscribe, scroll.getSnapshot, scroll.getSnapshot); |
| 142 | const activeDetails = details && source.getNodeSnapshot(details)?.kind === "tool" ? details : undefined; |
| 143 | const drawerWasOpen = useRef(false); |
| 144 | useLayoutEffect(() => { |
| 145 | if (!activeDetails && drawerWasOpen.current) { |
| 146 | (trigger.current?.isConnected ? trigger.current : scroller.current)?.focus({ preventScroll: true }); |
| 147 | trigger.current = null; |
| 148 | } |
| 149 | drawerWasOpen.current = Boolean(activeDetails); |
| 150 | }, [activeDetails]); |
| 151 | const [pagingError, setPagingError] = useState(false); |
| 152 | const [selectionBlocked, setSelectionBlocked] = useState(false); |
| 153 | // Manual paging and navigation jumps share one queue. A page already in |
| 154 | // flight is awaited rather than submitted twice, so a jump that collides |
| 155 | // with the button continues from that page instead of failing. |
| 156 | const pagingPromise = useRef<Promise<HistoryLoadOutcome> | null>(null); |
| 157 | const selectionInsideTranscript = () => { |
| 158 | const selection = window.getSelection?.(); |
| 159 | return Boolean(selection && !selection.isCollapsed && scroller.current && |
| 160 | ((selection.anchorNode && scroller.current.contains(selection.anchorNode)) || |
| 161 | (selection.focusNode && scroller.current.contains(selection.focusNode)))); |
| 162 | }; |
| 163 | useEffect(() => { |
| 164 | const clear = () => { if (!selectionInsideTranscript()) setSelectionBlocked(false); }; |
| 165 | document.addEventListener("selectionchange", clear); |
| 166 | return () => document.removeEventListener("selectionchange", clear); |
| 167 | }, []); |
| 168 | const loadPage = (direction: "older" | "newer" | "latest", trigger: HistoryLoadTrigger = "viewport-user"): Promise<HistoryLoadOutcome> => { |
| 169 | if (pagingPromise.current) return pagingPromise.current; |
| 170 | const load = direction === "older" ? () => onLoadOlderHistory?.(undefined, trigger) : () => onLoadNewerHistory?.(direction === "latest"); |
| 171 | if (direction === "older" ? !onLoadOlderHistory : !onLoadNewerHistory) return Promise.resolve("empty"); |
| 172 | if (selectionInsideTranscript()) { setSelectionBlocked(true); return Promise.resolve("empty"); } |
| 173 | const generation = lifetime.current; |
| 174 | setPagingError(false); |
| 175 | setSelectionBlocked(false); |
| 176 | scroll.beforeChange(); |
| 177 | const run = (async (): Promise<HistoryLoadOutcome> => { |
| 178 | try { |
| 179 | // A host that still answers with a plain boolean is normalized here. |
| 180 | const result = await load(); |
| 181 | if (result === true) return "loaded"; |
| 182 | if (result === false) return "empty"; |
| 183 | return result ?? "empty"; |
| 184 | } catch { |
| 185 | if (generation === lifetime.current) setPagingError(true); |
| 186 | return "empty"; |
| 187 | } |
| 188 | })(); |
| 189 | pagingPromise.current = run; |
| 190 | void run.finally(() => { if (pagingPromise.current === run) pagingPromise.current = null; }); |
| 191 | return run; |
| 192 | }; |
| 193 | const loadOlder = (trigger: HistoryLoadTrigger = "viewport-user") => loadPage("older", trigger); |
| 194 | // The jump outlives a single render, so it reads the live paging state |
| 195 | // through refs rather than through the closure it was built with. |
| 196 | const loadOlderRef = useRef(loadOlder); loadOlderRef.current = loadOlder; |
| 197 | const hasOlderRef = useRef(false); hasOlderRef.current = hasOlderHistory && Boolean(onLoadOlderHistory); |
| 198 | const lifetimeRef = useRef(lifetime.current); lifetimeRef.current = lifetime.current; |
| 199 | // Rebuild the mounted identity index only when the mount advances, then |
| 200 | // resolve each target from it in constant time: scanning the mounted order |
| 201 | // per outline entry is quadratic on long conversations. |
| 202 | const turnIndex = useRef<{ order: readonly string[]; index: LoadedTurnIndex }>(undefined); |
| 203 | const jump = useMemo(() => new ChatTurnJump({ |
| 204 | mounts, scroll, |
| 205 | loadOlder: () => loadOlderRef.current("question-jump"), |
| 206 | hasOlder: () => hasOlderRef.current, |
| 207 | resolveKey: (entry) => { |
| 208 | const order = mounts.getSnapshot(); |
| 209 | if (turnIndex.current?.order !== order) { |
| 210 | turnIndex.current = { |
| 211 | order, |
| 212 | index: indexLoadedTurns(order, (key) => { |
| 213 | const node = source.getNodeSnapshot(key); |
| 214 | return node?.kind === "user" ? { id: node.item.id, messageId: node.item.messageId } : undefined; |
| 215 | }), |
| 216 | }; |
| 217 | } |
| 218 | return findLoadedTurn(turnIndex.current.index, entry); |
| 219 | }, |
| 220 | // The rail describes one snapshot. A replacement invalidates the locators |
| 221 | // this jump was resolved against, so it must not keep paging the new body. |
| 222 | currentSnapshotId: () => (tabId ? getTranscriptOutlineStore().getView(tabId).snapshotId : ""), |
| 223 | // Only a reader-initiated retry reaches this, and it is what lets a target |
| 224 | // resolve against a fresh cut instead of the recycled one. |
| 225 | refreshSnapshot: async (entry) => { |
| 226 | if (!tabId) return undefined; |
| 227 | const store = getTranscriptOutlineStore(); |
| 228 | await store.refresh(tabId); |
| 229 | return store.resolve(tabId, entry); |
| 230 | }, |
| 231 | isCurrent: () => lifetimeRef.current === lifetime.current, |
| 232 | }), [mounts, scroll, source, tabId]); |
| 233 | const jumpState = useSyncExternalStore(jump.subscribe, jump.getSnapshot, jump.getSnapshot); |
| 234 | useEffect(() => () => jump.dispose(), [jump]); |
| 235 | useEffect(() => { |
| 236 | if (jumpState.status !== "failed") return; |
| 237 | setPagingError(true); |
| 238 | addBreadcrumb("chat.jump", `turn jump failed: ${jumpState.reason ?? "unknown"}`); |
| 239 | }, [jumpState.status, jumpState.reason]); |
| 240 | return <InvocationMetadataContext.Provider value={props.invocationMetadata ?? {}}> |
| 241 | <MarkdownImageTabContext.Provider value={tabId ?? ""}> |
| 242 | <ChatFileScopeProvider scopeKey={source.sessionKey} tabId={tabId} hostId={props.hostId}> |
| 243 | <section className="chat-transcript"> |
| 244 | <div className="chat-surface" inert={Boolean(activeDetails)}> |
| 245 | <Suspense fallback={null}><ChatTurnNavigator source={source} scroll={scroll} mounts={mounts} |
| 246 | tabId={tabId} knownTurns={props.totalTurns ?? 0} |
| 247 | busyTurn={jumpState.status === "loading" ? jumpState.turn : null} |
| 248 | failedTurn={jumpState.status === "failed" ? jumpState.turn : null} |
| 249 | failedReason={jumpState.reason} |
| 250 | // Every click takes the one transaction entry point, so a newer |
| 251 | // selection always supersedes a pending jump instead of racing it. |
| 252 | onNavigate={(target) => { |
| 253 | if (target.anchor.kind === "loaded") jump.jumpTo(target.anchor.key); |
| 254 | else void jump.jump(target.entry); |
| 255 | }} |
| 256 | onRetryJump={() => { void jump.retry(); }} |
| 257 | onCancelJump={() => jump.cancel()} /></Suspense> |
| 258 | <div ref={scroller} id={`reasonix-chat-transcript-${tabId ?? "local"}`} className="transcript chat-flow-scroll" tabIndex={0} data-transcript-render-mode="full" |
| 259 | data-transcript-hydrating={hydrating} data-scroll-mode={position.following ? "tail" : "reader"}> |
| 260 | <div ref={column} className="chat-column"> |
| 261 | <TranscriptConnection tabId={tabId} /> |
| 262 | {hydrating && <p role="status">{t("chat.loading")}</p>} |
| 263 | {(hasOlderHistory || hasNewerHistory) && <div className="chat-history-window" role="status"> |
| 264 | <span>{t("chat.historyRange", { start: Math.max(1, (props.historyStartTurn ?? 0) + 1), end: Math.max(1, props.historyEndTurn ?? props.totalTurns ?? 0), total: props.totalTurns ?? 0 })}</span> |
| 265 | </div>} |
| 266 | {hasOlderHistory && <button className="btn chat-older" disabled={loadingOlderHistory} onClick={() => void loadOlder()}>{t(loadingOlderHistory ? "chat.loading" : "chat.loadOlder")}</button>} |
| 267 | {(olderHistoryError || pagingError) && <button className="btn" onClick={() => void loadOlder()}>{t("chat.loadFailed")}</button>} |
| 268 | {selectionBlocked && <p className="chat-history-selection" role="status">{t("chat.historySelectionBlocked")}</p>} |
| 269 | {!hydrating && items.length === 0 && !running && <Welcome onPrompt={onPrompt} />} |
| 270 | <ChatNodeList key={source.sessionKey} source={source} mounts={mounts} loader={loader} scroll={scroll} actions={actions} tabId={tabId} hostId={props.hostId} /> |
| 271 | <ChatRunning source={source} /> |
| 272 | {hasNewerHistory && <div className="chat-history-newer"> |
| 273 | <button className="btn" disabled={loadingNewerHistory} onClick={() => void loadPage("newer")}>{t(loadingNewerHistory ? "chat.loading" : "chat.loadNewer")}</button> |
| 274 | <button className="btn" disabled={loadingNewerHistory} onClick={() => void loadPage("latest")}>{t("chat.toLatest")}</button> |
| 275 | </div>} |
| 276 | {newerHistoryError && <button className="btn" onClick={() => void loadPage("newer")}>{t("chat.loadFailed")}</button>} |
| 277 | </div> |
| 278 | </div> |
| 279 | <button className="btn chat-to-bottom" hidden={position.following} aria-label={t("chat.toLatest")} onClick={scroll.toBottom}><ArrowDown size={18} /></button> |
| 280 | </div> |
| 281 | {activeDetails && <ChatDetails key={activeDetails} source={source} nodeKey={activeDetails} loader={loader} onClose={closeDetails} onNavigate={setDetails} />} |
| 282 | </section> |
| 283 | </ChatFileScopeProvider> |
| 284 | </MarkdownImageTabContext.Provider> |
| 285 | </InvocationMetadataContext.Provider>; |
| 286 | } |
| 287 |