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