返回 DeepSeek-Reasonix
ChatTurnNavigator.tsx
根目录 / desktop / frontend / src / components / ChatTurnNavigator.tsx
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
101 lines Plain Text