| 1 | import { useCallback, useEffect, useMemo, useRef, 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 { getTranscriptOutlineStore, localOutlineRead, remoteOutlineRead } from "../lib/transcriptOutlineStore"; |
| 6 | import type { TurnJumpReason } from "../lib/chatTurnJump"; |
| 7 | import { useT } from "../lib/i18n"; |
| 8 | import { TurnNavigator, type TurnRailItem } from "./harness-chat/TurnNavigator"; |
| 9 | import css from "./harness-chat/TurnNavigator.styles"; |
| 10 | import "./harness-chat/TurnNavigator.css"; |
| 11 | |
| 12 | export default function ChatTurnNavigator({ source, scroll, mounts, tabId, hostId, onNavigate, onRetryJump, onCancelJump, busyTurn, failedTurn, failedReason, knownTurns = 0 }: { |
| 13 | source: ChatSource; scroll: ChatScrollController; mounts: ChatMountedOrder; tabId?: string; hostId?: string; |
| 14 | onNavigate: (item: TurnRailItem) => void; onRetryJump?: () => void; onCancelJump?: () => void; |
| 15 | busyTurn?: string | null; failedTurn?: string | null; failedReason?: TurnJumpReason; knownTurns?: number; |
| 16 | }) { |
| 17 | const t = useT(); |
| 18 | const store = getTranscriptOutlineStore(); |
| 19 | const subscribe = useCallback((notify: () => void) => tabId ? store.subscribe(tabId, notify) : () => {}, [store, tabId]); |
| 20 | const read = useCallback(() => store.getView(tabId ?? ""), [store, tabId]); |
| 21 | const outline = useSyncExternalStore(subscribe, read, read); |
| 22 | const order = useSyncExternalStore(mounts.subscribe, mounts.getSnapshot, mounts.getSnapshot); |
| 23 | const subscribeIdentities = useCallback((notify: () => void) => { |
| 24 | const releases = order.filter(key => source.getNodeSnapshot(key)?.kind === "user").map(key => source.subscribeNode(key, notify)); |
| 25 | return () => { for (const release of releases) release(); }; |
| 26 | }, [order, source]); |
| 27 | const readIdentities = useCallback(() => JSON.stringify(order.flatMap(key => { |
| 28 | const node = source.getNodeSnapshot(key); |
| 29 | return node?.kind === "user" ? [[key, node.item.messageId, node.item.historyTurn, node.item.checkpointTurn]] : []; |
| 30 | })), [order, source]); |
| 31 | const identities = useSyncExternalStore(subscribeIdentities, readIdentities, readIdentities); |
| 32 | const position = useSyncExternalStore(scroll.subscribe, scroll.getSnapshot, scroll.getSnapshot); |
| 33 | const initialTurns = useRef(knownTurns).current; |
| 34 | useEffect(() => tabId ? store.activate(tabId, source.sessionKey, hostId && hostId !== "local" ? remoteOutlineRead : localOutlineRead, initialTurns) : undefined, |
| 35 | [store, tabId, source, hostId, initialTurns]); |
| 36 | const loaded = useMemo(() => { |
| 37 | const identityRows = new Map(JSON.parse(identities).map((row: [string, string?, number?, number?]) => [row[0], row])); |
| 38 | const result: TurnRailItem[] = []; |
| 39 | for (const key of order) { |
| 40 | const node = source.getNodeSnapshot(key); |
| 41 | if (node?.kind === "user") { |
| 42 | const row = identityRows.get(key) as [string, string?, number?, number?] | undefined; |
| 43 | const previous = result[result.length - 1]?.ordinal ?? (node.item.submissionId ? knownTurns : 0); |
| 44 | result.push({ turn: row?.[1] ? `m:${row[1]}` : key, |
| 45 | ordinal: row?.[2] ?? row?.[3] ?? previous + 1, prompt: "", response: "", anchor: { kind: "loaded", key } }); |
| 46 | } |
| 47 | else if (node?.kind === "assistant" && result.length) result[result.length - 1].answerKey = key; |
| 48 | } |
| 49 | return result; |
| 50 | }, [order, source, identities, knownTurns]); |
| 51 | const byTurn = useMemo(() => new Map(loaded.map(item => [item.ordinal, item])), [loaded]); |
| 52 | const byId = useMemo(() => new Map(loaded.map(item => [item.turn, item])), [loaded]); |
| 53 | const complete = Boolean(tabId) && outline.mode !== "unsupported"; |
| 54 | const count = complete ? Math.max(outline.totalTurns, knownTurns, ...loaded.map(item => item.ordinal), 0) : loaded.length; |
| 55 | const getItem = useCallback((index: number): TurnRailItem => { |
| 56 | // A legacy host only promises the order of its resident turns. Its |
| 57 | // projected history numbers can overlap during submission handoff, so use |
| 58 | // the resident order as the fallback rail position and React identity. |
| 59 | if (!complete) return { ...loaded[index], ordinal: index + 1 }; |
| 60 | const ordinal = index + 1; |
| 61 | const entry = outline.entries.get(ordinal); |
| 62 | const mounted = entry ? byId.get(`m:${entry.messageId}`) : byTurn.get(ordinal); |
| 63 | if (mounted) return { ...mounted, ordinal }; |
| 64 | return { turn: entry ? `m:${entry.messageId}` : `pending:${ordinal}`, ordinal, |
| 65 | prompt: entry?.prompt ?? t("chat.loading"), response: entry?.answer ?? "", |
| 66 | anchor: entry ? { kind: "unloaded", recordId: `m:${entry.messageId}`, messageId: entry.messageId } : { kind: "pending" }, unloaded: true }; |
| 67 | }, [complete, loaded, outline.entries, byId, byTurn, t]); |
| 68 | const loadedIndex = loaded.findIndex(item => item.anchor.kind === "loaded" && item.anchor.key === position.activeKey); |
| 69 | const activeIndex = complete && loadedIndex >= 0 ? loaded[loadedIndex].ordinal - 1 : loadedIndex; |
| 70 | const range = useCallback((first: number, last: number) => { |
| 71 | if (tabId && complete && outline.snapshotSequence !== undefined) void store.ensure(tabId, first + 1, last); |
| 72 | }, [tabId, complete, store, outline.snapshotSequence]); |
| 73 | const preview = useCallback((item: TurnRailItem) => <Preview source={source} item={item} />, [source]); |
| 74 | const failed = outline.mode === "error"; |
| 75 | return <> |
| 76 | <TurnNavigator items={loaded} totalCount={count} itemAt={getItem} activeIndex={activeIndex} onRange={range} |
| 77 | activeTurn={position.activeKey || null} busyTurn={busyTurn ?? null} onNavigate={onNavigate} renderPreview={preview} t={t} |
| 78 | loading={complete && outline.mode === "loading" && count > 1} failed={failed} |
| 79 | onRetry={failed ? () => { if (tabId) void store.retry(tabId); } : failedTurn ? onRetryJump : undefined} |
| 80 | jumpFailed={Boolean(failedTurn)} jumpReasonKey={failedReason === "snapshotExpired" ? "chat.turnNavigation.reasonExpired" : failedReason === "mountTimeout" ? "chat.turnNavigation.reasonMount" : "chat.turnNavigation.reasonUnavailable"} |
| 81 | onCancelJump={busyTurn ? onCancelJump : undefined} /> |
| 82 | {outline.mode === "unsupported" && knownTurns > loaded.length && <span className="chat-turn-navigation-status" role="status">{t("chat.turnNavigation.upgrade")}</span>} |
| 83 | </>; |
| 84 | } |
| 85 | |
| 86 | function Preview({ source, item }: { source: ChatSource; item: TurnRailItem }) { |
| 87 | const subscribe = useCallback((notify: () => void) => { |
| 88 | const user = item.anchor.kind === "loaded" ? source.subscribeNode(item.anchor.key, notify) : undefined; |
| 89 | const answer = item.answerKey ? source.subscribeNode(item.answerKey, notify) : undefined; |
| 90 | return () => { user?.(); answer?.(); }; |
| 91 | }, [source, item]); |
| 92 | const read = useCallback(() => { |
| 93 | const user = item.anchor.kind === "loaded" ? source.getNodeSnapshot(item.anchor.key) : undefined; |
| 94 | const answer = item.answerKey ? source.getNodeSnapshot(item.answerKey) : undefined; |
| 95 | return JSON.stringify([user?.kind === "user" ? user.item.text.slice(0, 300) : item.prompt, |
| 96 | answer?.kind === "assistant" ? answer.item.text.slice(0, 500) : item.response]); |
| 97 | }, [source, item]); |
| 98 | const [prompt, response] = JSON.parse(useSyncExternalStore(subscribe, read, read)) as string[]; |
| 99 | return <><div className={css.previewPrompt}>{prompt || item.ordinal}</div><div className={css.previewResponse}>{response}</div></>; |
| 100 | } |
| 101 |