| 1 | import { useCallback, useMemo, useSyncExternalStore } from "react"; |
| 2 | import type { ChatSource } from "../lib/chatViewSource"; |
| 3 | import type { ChatScrollController } from "../lib/chatScrollController"; |
| 4 | import type { ChatMountedOrder } from "../lib/chatMountedOrder"; |
| 5 | import { findLoadedTurn, indexLoadedTurns, type LoadedTurnNode } from "../lib/chatTurnRail"; |
| 6 | import { getTranscriptOutlineStore, type TranscriptOutlineView } from "../lib/transcriptOutlineStore"; |
| 7 | import type { TurnJumpReason } from "../lib/chatTurnJump"; |
| 8 | import type { TranscriptOutlineEntry } from "../lib/transcriptProtocol"; |
| 9 | import { useT } from "../lib/i18n"; |
| 10 | import { TurnNavigator, type TurnRailAnchor, type TurnRailItem } from "./harness-chat/TurnNavigator"; |
| 11 | import css from "./harness-chat/TurnNavigator.styles"; |
| 12 | import "./harness-chat/TurnNavigator.css"; |
| 13 | |
| 14 | function Preview({ source, item }: { source: ChatSource; item: TurnRailItem }) { |
| 15 | const subscribe = useCallback((notify: () => void) => { |
| 16 | const user = item.anchor.kind === "loaded" ? source.subscribeNode(item.anchor.key, notify) : undefined; |
| 17 | const answer = item.answerKey ? source.subscribeNode(item.answerKey, notify) : undefined; |
| 18 | return () => { user?.(); answer?.(); }; |
| 19 | }, [source, item]); |
| 20 | const snapshot = useCallback(() => { |
| 21 | // An unloaded turn has no node to read, so its outline preview is used as |
| 22 | // it arrived. Prefer the loaded body when there is one: it carries the |
| 23 | // running turn's text that the snapshot could not have seen yet. |
| 24 | const user = item.anchor.kind === "loaded" ? source.getNodeSnapshot(item.anchor.key) : undefined; |
| 25 | const answer = item.answerKey ? source.getNodeSnapshot(item.answerKey) : undefined; |
| 26 | const prompt = user?.kind === "user" ? user.item.text.slice(0, 300) : item.prompt; |
| 27 | const response = answer?.kind === "assistant" && answer.item.text.trim() ? answer.item.text.slice(0, 500) : item.response; |
| 28 | return JSON.stringify([prompt, response]); |
| 29 | }, [source, item]); |
| 30 | const [prompt, response] = JSON.parse(useSyncExternalStore(subscribe, snapshot, snapshot)) as string[]; |
| 31 | return <><div className={css.previewPrompt}>{prompt || item.ordinal}</div><div className={css.previewResponse}>{response}</div></>; |
| 32 | } |
| 33 | |
| 34 | export default function ChatTurnNavigator({ source, scroll, mounts, tabId, onNavigate, onRetryJump, onCancelJump, busyTurn, failedTurn, failedReason, knownTurns = 0 }: { |
| 35 | source: ChatSource; |
| 36 | scroll: ChatScrollController; |
| 37 | mounts: ChatMountedOrder; |
| 38 | tabId?: string; |
| 39 | /** The single entry point for every click: loaded and unloaded alike. */ |
| 40 | onNavigate?: (target: { anchor: TurnRailAnchor; entry: TranscriptOutlineEntry }) => void; |
| 41 | onRetryJump?: () => void; |
| 42 | onCancelJump?: () => void; |
| 43 | busyTurn?: string | null; |
| 44 | failedTurn?: string | null; |
| 45 | /** Why the last jump failed, so the retry can say what it is recovering. */ |
| 46 | failedReason?: TurnJumpReason; |
| 47 | /** Turns the session is already known to hold, independent of the outline. */ |
| 48 | knownTurns?: number; |
| 49 | }) { |
| 50 | const t = useT(); |
| 51 | const store = useMemo(() => getTranscriptOutlineStore(), []); |
| 52 | // An unknown tab id resolves to the store's frozen legacy view, so the |
| 53 | // snapshot stays referentially stable when no tab is bound yet. |
| 54 | const subscribeOutline = useCallback((notify: () => void) => (tabId ? store.subscribe(tabId, notify) : () => {}), [store, tabId]); |
| 55 | const readOutline = useCallback((): TranscriptOutlineView => store.getView(tabId ?? ""), [store, tabId]); |
| 56 | const outline = useSyncExternalStore(subscribeOutline, readOutline, readOutline); |
| 57 | const order = useSyncExternalStore(mounts.subscribe, mounts.getSnapshot, mounts.getSnapshot); |
| 58 | const position = useSyncExternalStore(scroll.subscribe, scroll.getSnapshot, scroll.getSnapshot); |
| 59 | |
| 60 | // A new array identity whenever the outline changes so the merge re-runs. |
| 61 | const outlineEntries = outline.entries; |
| 62 | |
| 63 | const items = useMemo(() => { |
| 64 | const turns: TurnRailItem[] = []; |
| 65 | const byTurn = new Map<string, TurnRailItem>(); |
| 66 | const identity = new Map<string, LoadedTurnNode>(); |
| 67 | for (const key of order) { |
| 68 | const node = source.getNodeSnapshot(key); |
| 69 | if (node?.kind === "user") { |
| 70 | const item: TurnRailItem = { |
| 71 | turn: key, ordinal: turns.length + 1, prompt: "", response: "", |
| 72 | anchor: { kind: "loaded", key }, |
| 73 | }; |
| 74 | turns.push(item); |
| 75 | byTurn.set(key, item); |
| 76 | identity.set(key, { id: node.item.id, messageId: node.item.messageId }); |
| 77 | } else if (node?.kind === "assistant" && turns.length) turns[turns.length - 1].answerKey = key; |
| 78 | } |
| 79 | if (outline.mode !== "ready") return turns; |
| 80 | |
| 81 | // The outline is the complete conversation; loaded turns only enrich it. |
| 82 | // Ordering and numbering come from the outline so loading an earlier page |
| 83 | // never renumbers the rail. One pass builds the identity index the merge |
| 84 | // then resolves from in constant time. |
| 85 | const loaded = indexLoadedTurns(order, (key) => identity.get(key)); |
| 86 | const merged: TurnRailItem[] = []; |
| 87 | // Mark identities already emitted, and the mounted nodes they consumed, so |
| 88 | // a turn is never listed twice under two different identities. |
| 89 | const emitted = new Set<string>(); |
| 90 | const claimed = new Set<string>(); |
| 91 | for (const entry of outlineEntries) { |
| 92 | const key = findLoadedTurn(loaded, entry); |
| 93 | // The mark keeps the outline's record id as its identity for its whole |
| 94 | // life, so finishing a load never remounts it or moves its position. |
| 95 | if (emitted.has(entry.id)) continue; |
| 96 | emitted.add(entry.id); |
| 97 | if (key !== undefined) claimed.add(key); |
| 98 | const mounted = key ? byTurn.get(key) : undefined; |
| 99 | merged.push({ |
| 100 | turn: entry.id, |
| 101 | ordinal: entry.turn > 0 ? entry.turn : merged.length + 1, |
| 102 | prompt: mounted?.prompt || entry.prompt, |
| 103 | response: mounted?.response || entry.answer || "", |
| 104 | answerKey: mounted?.answerKey, |
| 105 | anchor: key ? { kind: "loaded", key } : { kind: "unloaded", recordId: entry.id, messageId: entry.messageId }, |
| 106 | unloaded: key === undefined, |
| 107 | }); |
| 108 | } |
| 109 | // A question submitted while the outline was being read is not in it yet. |
| 110 | // Keep it rather than dropping a turn the reader can already see. |
| 111 | for (const item of turns) { |
| 112 | if (claimed.has(item.turn) || emitted.has(item.turn)) continue; |
| 113 | emitted.add(item.turn); |
| 114 | merged.push({ ...item, ordinal: merged.length + 1, unloaded: false }); |
| 115 | } |
| 116 | return merged; |
| 117 | }, [order, source, outline.mode, outlineEntries]); |
| 118 | |
| 119 | // Every click goes through the caller's single transaction entry point so a |
| 120 | // newer selection supersedes a pending jump instead of racing it. |
| 121 | const navigate = useCallback((item: TurnRailItem) => { |
| 122 | onNavigate?.({ |
| 123 | anchor: item.anchor, |
| 124 | entry: { |
| 125 | id: item.anchor.kind === "unloaded" ? item.anchor.recordId : item.turn, |
| 126 | messageId: item.anchor.kind === "unloaded" ? item.anchor.messageId : undefined, |
| 127 | turn: item.ordinal, order: 0, prompt: item.prompt, answer: item.response, |
| 128 | }, |
| 129 | }); |
| 130 | }, [onNavigate]); |
| 131 | const reloadOutline = useCallback(() => { if (tabId) void store.retry(tabId); }, [store, tabId]); |
| 132 | const preview = useCallback((item: TurnRailItem) => <Preview key={item.turn} source={source} item={item} />, [source]); |
| 133 | // Only a session already known to hold more than one turn keeps the rail's |
| 134 | // area while the outline is still loading; a fresh conversation shows nothing. |
| 135 | // A failure is different: whatever markers are already known stay, and the |
| 136 | // retry entry is offered whether or not any are. |
| 137 | const failed = outline.mode === "error"; |
| 138 | const loading = knownTurns > 1 && items.length < 2 && outline.mode === "loading"; |
| 139 | // A failed jump is retried against its own target, not by reading more |
| 140 | // history; a jump that is still paging offers its own cancel. |
| 141 | const jumpFailed = failedTurn !== null && failedTurn !== undefined; |
| 142 | // A budget running out and a recycled cut are different situations, so the |
| 143 | // retry says which one it is recovering from. |
| 144 | const jumpReasonKey = !jumpFailed ? undefined |
| 145 | : failedReason === "pageBudgetExhausted" ? "chat.turnNavigation.reasonBudget" |
| 146 | : failedReason === "snapshotExpired" ? "chat.turnNavigation.reasonExpired" |
| 147 | : "chat.turnNavigation.reasonUnavailable"; |
| 148 | return <TurnNavigator items={items} activeTurn={position.activeKey || null} busyTurn={busyTurn ?? null} |
| 149 | onNavigate={navigate} renderPreview={preview} t={t} |
| 150 | loading={loading} failed={failed} onRetry={failed ? reloadOutline : jumpFailed ? onRetryJump : undefined} |
| 151 | jumpFailed={jumpFailed} jumpReasonKey={jumpReasonKey} truncated={outline.truncated} |
| 152 | onCancelJump={busyTurn ? onCancelJump : undefined} />; |
| 153 | } |
| 154 |