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