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