返回 DeepSeek-Reasonix
ChatNodes.tsx
根目录 / desktop / frontend / src / components / ChatNodes.tsx
1 import { createContext, lazy, Suspense, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2 import { FileText, Globe, GitBranch, PackageOpen, Search, Terminal, Users, Wrench, X } from "lucide-react";
3 import { ErrorMessage } from "./ErrorMessage";
4 import { ChatSource, type ChatNode } from "../lib/chatViewSource";
5 import type { ChatContentLoader } from "../lib/chatContentLoader";
6 import type { ChatScrollController } from "../lib/chatScrollController";
7 import { reconcileMountedOrder, revealEarlierMountedOrder, type ChatMountedOrder } from "../lib/chatMountedOrder";
8 import { forkBlockReason, forkReasonKey, type ForkBlockReason, type ForkTargetView } from "../lib/forkTargets";
9 import { useT } from "../lib/i18n";
10 import { AssistantMessage, UserMessage } from "./Message";
11 import { CopyButton } from "./CopyButton";
12 import { ExtensionCard } from "./ExtensionCard";
13 import { Tooltip } from "./Tooltip";
14 import { formatMessageClock, TurnTimePanel, TurnUsagePanel } from "./TurnStats";
15 import { ReasoningRow } from "./harness-chat/ReasoningRow";
16 import { TurnProcessNodeView } from "./harness-chat/TurnProcessNodeView";
17 import { ContextInjectionRow } from "./harness-chat/ContextInjectionRow";
18 import { ToolRow } from "./harness-chat/ToolRow";
19 import { CompactionCard } from "./TranscriptCards";
20 import { subjectOf, summarizeFileDiff } from "../lib/tools";
21 import { classifyTool, shellDisplayName, toolPresentation } from "../lib/chatToolPresentation";
22 import { RESOURCE_BUDGETS } from "../lib/resourceBudgets";
23 import type { WireCompletionSummary } from "../lib/types";
24 const ChatToolBody = lazy(() => import("./ChatToolBody"));
25 const ToolPayload = lazy(() => import("./ChatToolBody").then(module => ({ default: module.ToolPayload })));
26 const PresentedFiles = lazy(() => import("./PresentedFiles").then(module => ({ default: module.PresentedFiles })));
27 const ModifiedFiles = lazy(() => import("./PresentedFiles").then(module => ({ default: module.ModifiedFiles })));
28 const TOOL_RELATION_PAGE_SIZE = RESOURCE_BUDGETS.toolRelationsPerPage;
29
30 export function useChatNode(source: ChatSource, key: string) {
31 const subscribe = useCallback((listener: () => void) => source.subscribeNode(key, listener), [source, key]);
32 const snapshot = useCallback(() => source.getNodeSnapshot(key), [source, key]);
33 return useSyncExternalStore(subscribe, snapshot, snapshot);
34 }
35
36 function useProcessVisibility(source: ChatSource, node: ChatNode | undefined) {
37 const processKey = `${node?.turnKey}:process`;
38 const key = node?.key, kind = node?.kind;
39 const subscribe = useCallback((listener: () => void) => source.subscribeNode(processKey, listener), [source, processKey]);
40 const snapshot = useCallback(() => {
41 const process = source.getNodeSnapshot(processKey);
42 if (process?.kind !== "process" || !process.collapsed || !key) return "visible";
43 if (process.members.includes(key)) return "hidden";
44 return kind === "assistant" ? "answer" : "visible";
45 }, [source, processKey, key, kind]);
46 // Counts and membership grow with every tool call. Only a visibility change
47 // should rerender an unchanged row; the process seat owns the full summary.
48 return useSyncExternalStore(subscribe, snapshot, snapshot);
49 }
50 /**
51 * The transcript's fork affordance. Every question about one turn is answered
52 * from the host's persisted turn records, so the entry never depends on
53 * checkpoints, on whether the session is running, or on a page offset.
54 */
55 export type ChatForkAction = {
56 /** Persisted boundary of a tail's answer message, undefined when the source keeps none. */
57 targetFor: (answerKey: string | undefined) => ForkTargetView | undefined;
58 /** False until this session's target set arrives; every entry reads as loading. */
59 loaded: boolean;
60 /** True when the source keeps persisted turn records at all. */
61 verifiable: boolean;
62 /** Non-null replaces every entry's own state, e.g. a create request already in flight. */
63 blocked: ForkBlockReason | null;
64 create: (target: ForkTargetView) => void;
65 };
66 export type ChatActions = {
67 openDetails: (key: string, trigger: HTMLElement) => void;
68 /** Absent on surfaces that cannot fork at all; those render no branch entry. */
69 fork?: ChatForkAction;
70 recover: (id: string) => void;
71 openTurnChanges?: (summary: WireCompletionSummary, initialPath?: string) => void;
72 };
73 const ChatForkContext = createContext<ChatForkAction | undefined>(undefined);
74 type SeatProps = { source: ChatSource; nodeKey: string; loader: ChatContentLoader; scroll: ChatScrollController; actions: ChatActions; tabId?: string; hostId?: string };
75
76 export const ChatNodeList = memo(function ChatNodeList(props: Omit<SeatProps, "nodeKey"> & { mounts: ChatMountedOrder }) {
77 const { openDetails, recover, openTurnChanges, fork } = props.actions;
78 // Fork availability belongs to the turn actions, not every tool/message row.
79 const actions = useMemo(() => ({ openDetails, recover, openTurnChanges }), [openDetails, recover, openTurnChanges]);
80 const order = useSyncExternalStore(props.source.subscribeOrder, props.source.getOrderSnapshot, props.source.getOrderSnapshot);
81 const [visible, setVisible] = useState<readonly string[]>(order);
82 const committedVisibleRef = useRef(visible);
83 committedVisibleRef.current = visible;
84 const visibleRef = useRef(visible);
85 // Harness keeps every business node under one keyed parent. Tail growth and
86 // replacements are synchronous; a leading history page alone is revealed in
87 // bounded frames without ever regrouping the nodes already in the document.
88 const rendered = reconcileMountedOrder(visible, order);
89 visibleRef.current = rendered;
90 useLayoutEffect(() => props.mounts.publish(rendered), [props.mounts, rendered]);
91 useEffect(() => {
92 let frame = requestAnimationFrame(function revealPrepend() {
93 const current = visibleRef.current;
94 if (!current.length) { visibleRef.current = order; committedVisibleRef.current = order; setVisible(order); return; }
95 const next = revealEarlierMountedOrder(current, order);
96 if (next === current) {
97 // Tail growth is visible immediately. Commit the reconciled order so a
98 // later history prepend starts from the actual mounted suffix.
99 if (committedVisibleRef.current !== current) {
100 committedVisibleRef.current = current;
101 setVisible(current);
102 }
103 return;
104 }
105 visibleRef.current = next;
106 committedVisibleRef.current = next;
107 setVisible(next);
108 if (next.length < order.length) frame = requestAnimationFrame(revealPrepend);
109 });
110 return () => cancelAnimationFrame(frame);
111 }, [order]);
112 return <ChatForkContext.Provider value={fork}>{rendered.map(key => <ChatNodeSeat key={key} source={props.source} loader={props.loader} scroll={props.scroll}
113 actions={actions} tabId={props.tabId} hostId={props.hostId} nodeKey={key} />)}</ChatForkContext.Provider>;
114 });
115
116 const ChatNodeSeat = memo(function ChatNodeSeat({ source, nodeKey, loader, scroll, actions, tabId, hostId }: SeatProps) {
117 const node = useChatNode(source, nodeKey);
118 const visibility = useProcessVisibility(source, node);
119 const t = useT();
120 if (!node || visibility === "hidden") return null;
121 let body;
122 switch (node.kind) {
123 case "user": body = <ChatUser node={node} loader={loader} />; break;
124 case "assistant": body = node.item.text || node.item.searchSources?.length || node.item.memoryCitations?.length || loader.needsFullContent(node.item, "content") ? <ChatAnswer node={node} loader={loader} source={source} tabId={tabId} hostId={hostId} /> : null; break;
125 case "reasoning": body = node.item.reasoning || loader.needsFullContent(node.item, "reasoning") ? <ChatReasoning node={node} loader={loader} source={source} scroll={scroll} /> : null; break;
126 case "process": body = <TurnProcessNodeView node={node} onToggle={() => { scroll.beforeChange(); source.toggleProcess(node.turnKey); }} />; break;
127 case "tool": body = <ChatTool node={node} loader={loader} actions={actions} scroll={scroll} />; break;
128 case "phase": body = <ContextInjectionRow title={t("chat.activity")} summary={node.item.text} beforeToggle={scroll.beforeChange}>{node.item.text}</ContextInjectionRow>; break;
129 case "notice": body = <ChatNotice node={node} actions={actions} scroll={scroll} />; break;
130 case "compaction": body = <ChatCompaction node={node} loader={loader} />; break;
131 case "extension": body = node.item.card.actions?.length ? <ExtensionCard item={node.item} tabId={tabId} /> :
132 <ChatDisclosure label={node.item.card.title || node.item.pluginId}><ExtensionCard item={node.item} tabId={tabId} /></ChatDisclosure>; break;
133 case "tail": body = <ChatTurnTail node={node} source={source} actions={actions} loader={loader} tabId={tabId} hostId={hostId} />; break;
134 }
135 return <div className="chat-node" data-chat-anchor-key={node.key} data-chat-turn={node.turnKey} data-chat-kind={node.kind}
136 data-turn-process-answer={visibility === "answer" || undefined}>{body}</div>;
137 });
138
139 function ChatNotice({ node, actions, scroll }: { node: Extract<ChatNode, { kind: "notice" }>; actions: ChatActions; scroll: ChatScrollController }) {
140 const t = useT();
141 const item = node.item;
142 const summary = item.completionSummary;
143 if (item.code === "capability_proxy_audit") return <ChatDisclosure label={t("chat.details")}><pre>{item.text}{"\n"}{item.detail}</pre></ChatDisclosure>;
144 // Empty delivery accounting is not a chat result. Keep meaningful records in details.
145 if (summary && !summary.mutations && !summary.changed_files && !summary.checks_passed && !summary.checks_failed) return null;
146 if (item.level === "warn" || item.action === "recover_context") return <div className="chat-notice" role="status" data-level={item.level}>
147 {item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} diagnostic={item.diagnostic} />
148 {summary && <details className="chat-notice__details"><summary>{t("chat.details")}</summary><pre>{JSON.stringify(summary, null, 2)}</pre></details>}
149 {item.detail && <ChatDisclosure label={t("chat.details")}><pre>{item.detail}</pre></ChatDisclosure>}
150 {item.action === "recover_context" && item.recoveryId && <button className="btn" onClick={() => actions.recover(item.recoveryId!)}>{t("notice.protocolRecoveryAction")}</button>}
151 </div>;
152 return <ContextInjectionRow title={item.title || t(item.decisionReceipt ? "chat.decision" : summary ? "chat.record" : "chat.notice")}
153 summary={item.decisionReceipt ? undefined : item.text.split("\n")[0]} beforeToggle={scroll.beforeChange}>
154 <pre>{item.text}{item.detail ? `\n${item.detail}` : ""}{summary ? `\n${JSON.stringify(summary, null, 2)}` : ""}</pre>
155 </ContextInjectionRow>;
156 }
157
158 function ChatCompaction({ node, loader }: { node: Extract<ChatNode, { kind: "compaction" }>; loader: ChatContentLoader }) {
159 const [loadFailed, setLoadFailed] = useState(false);
160 const needsContent = loader.needsFullContent(node.item, "summary");
161 useEffect(() => {
162 if (!needsContent || loadFailed) return;
163 let alive = true;
164 void loader.load(node.item, "summary").catch(() => { if (alive) setLoadFailed(true); });
165 return () => { alive = false; };
166 }, [loader, node.item, needsContent, loadFailed]);
167 return <CompactionCard item={loadFailed && node.item.status === "loading"
168 ? { ...node.item, pending: false, status: "unavailable", errorCode: "record_incomplete" }
169 : node.item} />;
170 }
171
172 function ChatTool({ node, loader, actions, scroll }: { node: Extract<ChatNode, { kind: "tool" }>; loader: ChatContentLoader; actions: ChatActions; scroll: ChatScrollController }) {
173 const t = useT();
174 const item = node.item;
175 let args: Record<string, unknown> = {};
176 try { args = JSON.parse(item.args) || {}; } catch { /* Arguments may still be streaming. */ }
177 const description = typeof args.description === "string" ? args.description : "";
178 const presentCount = Array.isArray(args.files) ? args.files.length : item.presentedFiles?.length ?? 0;
179 const presentSummary = item.name === "present"
180 ? t(item.status === "running" ? "present.presenting" : item.status === "done" ? "present.presented" : "present.failed", { count: presentCount })
181 : "";
182 const summary = presentSummary || description || subjectOf(item.name, item.args) || item.subject || item.summary || "";
183 const toolKind = classifyTool(item);
184 const presentation = toolPresentation(item);
185 const Icon = toolKind === "present" ? PackageOpen : { search: Search, web: Globe, shell: Terminal, agent: Users, file: FileText, tool: Wrench }[toolKind];
186 const title = item.name === "web_search" ? t("chat.tool.search") : item.name === "web_fetch" ? t("chat.tool.web")
187 : item.name === "present" ? t("present.toolTitle") : toolKind === "shell" ? shellDisplayName(item) : item.resolvedName || item.name;
188 return <ToolRow icon={<Icon size={14} />} title={title} summary={[summary, summarizeFileDiff(item.fileDiff)].filter(Boolean).join(" · ")}
189 state={presentation.state} dot={presentation.dot} statusLabel={t(presentation.label)}
190 errorSummary={item.error?.trim().split("\n")[0]} beforeToggle={scroll.beforeChange}
191 inspectLabel={t("chat.details")} inspect={trigger => actions.openDetails(node.key, trigger)}>
192 <Suspense fallback={<p role="status">{t("chat.loading")}</p>}><ChatToolBody item={item} loader={loader} /></Suspense>
193 </ToolRow>;
194 }
195
196
197 function ChatDisclosure({ label, children }: { label: string; children: import("react").ReactNode }) {
198 const [open, setOpen] = useState(false);
199 return <details className="chat-notice" open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>{label}</summary>{open && children}</details>;
200 }
201
202 function BodyLoadError({ item, loader }: { item: Extract<ChatNode, { kind: "assistant" | "user" }>["item"]; loader: ChatContentLoader }) {
203 const [error, setError] = useState(false);
204 const [attempt, setAttempt] = useState(0);
205 const t = useT();
206 useEffect(() => {
207 let cancelled = false;
208 if (item.kind !== "assistant" || !item.streaming) void loader.load(item, "content").then(
209 () => { if (!cancelled) setError(false); }, () => { if (!cancelled) setError(true); });
210 return () => { cancelled = true; };
211 }, [loader, item, attempt]);
212 return error && <button className="btn" onClick={() => { setError(false); setAttempt(value => value + 1); }}>{t("chat.loadFailed")}</button>;
213 }
214 function ChatUser({ node, loader }: { node: Extract<ChatNode, { kind: "user" }>; loader: ChatContentLoader }) {
215 const t = useT();
216 return <><UserMessage id={node.item.id} text={node.item.text} submitText={node.item.submitText} failed={node.item.failed} createdAt={node.item.createdAt} />
217 {node.item.submissionState === "unknown" && !node.item.messageId && <span role="status">{t("chat.submissionUnknown")}</span>}
218 <BodyLoadError item={node.item} loader={loader} /></>;
219 }
220 function ChatAnswer({ node, loader, source, tabId, hostId }: { node: Extract<ChatNode, { kind: "assistant" }>; loader: ChatContentLoader; source: ChatSource; tabId?: string; hostId?: string }) {
221 const tail = useChatNode(source, `${node.turnKey}:tail`);
222 const presentedFiles = tail?.kind === "tail" ? tail.presentedFiles : [];
223 const modifiedFiles = tail?.kind === "tail" ? tail.modifiedFiles : [];
224 // A turn's file facts are the answers the host can already give without
225 // reading the answer text; when they grow, earlier reference failures are
226 // worth asking about again.
227 const factsVersion = presentedFiles.length + modifiedFiles.length;
228 return <><AssistantMessage item={node.item} presentedFiles={presentedFiles} modifiedFiles={modifiedFiles}
229 turnKey={node.turnKey} factsVersion={factsVersion} tabId={tabId} hostId={hostId} /><BodyLoadError item={node.item} loader={loader} /></>;
230 }
231
232 function ChatReasoning({ node, loader, source, scroll }: { node: Extract<ChatNode, { kind: "reasoning" }>; loader: ChatContentLoader; source: ChatSource; scroll: ChatScrollController }) {
233 const t = useT();
234 const [result, setResult] = useState<{ item: unknown; text: string }>();
235 const [following, setFollowing] = useState(false);
236 const [busy, setBusy] = useState(false);
237 const [error, setError] = useState(false);
238 const epoch = useRef(0);
239 useEffect(() => { return () => { epoch.current++; }; }, []);
240 const text = node.item.reasoning;
241 const streaming = Boolean(node.item.streaming);
242 const settled = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined;
243 const full = settled ?? (following ? text : undefined);
244 const load = async () => {
245 const ticket = ++epoch.current; setBusy(true); setError(false);
246 try {
247 const value = await loader.load(node.item, "reasoning");
248 const current = source.getNodeSnapshot(node.key);
249 if (ticket !== epoch.current || current?.kind !== "reasoning" || (current.item !== node.item && current.item.reasoning !== value)) throw new Error("Reasoning content changed; retry");
250 setResult({ item: current.item, text: value }); setFollowing(false); return value;
251 }
252 catch (error) { if (ticket === epoch.current) setError(true); throw error; }
253 finally { if (ticket === epoch.current) setBusy(false); }
254 };
255 useEffect(() => {
256 if (following && !streaming) void load().catch(() => {});
257 }, [following, streaming]);
258 if (!text) return null;
259 const needsFull = error || full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning"));
260 return <div className="chat-reasoning"><ReasoningRow text={text} running={node.item.streaming} t={t} beforeToggle={scroll.beforeChange}
261 duration={node.item.reasoningDurationMs != null ? `${(node.item.reasoningDurationMs / 1000).toFixed(1)}s` : undefined}>
262 <div className="chat-reasoning__body">{full ?? text.slice(0, 8000)}
263 {needsFull && <button className="btn" disabled={busy} onClick={() => streaming ? setFollowing(true) : void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
264 <CopyButton getText={() => full ?? (streaming ? text : load())} label={t("chat.copyFull")} />
265 </div></ReasoningRow></div>;
266 }
267
268 function ChatTurnTail({ node, source, actions, loader, tabId, hostId }: { node: Extract<ChatNode, { kind: "tail" }>; source: ChatSource; actions: ChatActions; loader: ChatContentLoader; tabId?: string; hostId?: string }) {
269 const answer = useChatNode(source, node.answerKey ?? "");
270 const fork = useContext(ChatForkContext);
271 const t = useT();
272 const hasAnswer = answer?.kind === "assistant" && Boolean(answer.item.text.trim());
273 const hasRecordedChanges = Boolean(node.completionSummary?.receipt?.diff?.files.length);
274 if (!hasAnswer && !node.presentedFiles.length && !node.modifiedFiles.length && !hasRecordedChanges) return null;
275 // A tail with no answer has no message identity, so it can name no boundary.
276 const target = hasAnswer ? fork?.targetFor(node.answerKey) : undefined;
277 const reason = fork ? forkBlockReason({ target, loaded: fork.loaded, verifiable: fork.verifiable, blocked: fork.blocked, latest: node.latest }) : null;
278 const reasonText = reason ? t(forkReasonKey(reason)) : "";
279 const create = fork?.create;
280 return <div className="chat-turn-tail">
281 {(node.modifiedFiles.length > 0 || hasRecordedChanges) && <Suspense fallback={null}>
282 <ModifiedFiles files={node.modifiedFiles} summary={node.completionSummary} tabId={tabId} hostId={hostId}
283 onOpenReview={actions.openTurnChanges} />
284 </Suspense>}
285 {node.presentedFiles.length > 0 && <Suspense fallback={null}>
286 <PresentedFiles files={node.presentedFiles} tabId={tabId} hostId={hostId} />
287 </Suspense>}
288 {hasAnswer && <div className="chat-actions" data-actions-reveal={node.latest ? "always" : "hover"}><CopyButton getText={async () => {
289 const text = answer.item.streaming ? answer.item.text : await loader.load(answer.item, "content");
290 const current = source.getNodeSnapshot(answer.key);
291 if (current?.kind !== "assistant" || (current.item !== answer.item && current.item.text !== text)) throw new Error("Answer changed; retry");
292 return text;
293 }} label={t("msg.copy")} showInlineLabel={false} className="chat-action-icon" />
294 {fork && <Tooltip label={reason ? reasonText : t("chat.branch")} side="bottom">
295 <button
296 type="button"
297 className="chat-action-icon"
298 aria-label={reason ? `${t("chat.branch")}: ${reasonText}` : t("chat.branch")}
299 aria-disabled={reason ? true : undefined}
300 data-unavailable={reason ? true : undefined}
301 onClick={reason || !target || !create ? undefined : () => create(target)}
302 ><GitBranch aria-hidden="true" /></button>
303 </Tooltip>}
304 {answer!.item.turnUsage && answer!.item.turnUsage.totalTokens > 0 && <TurnUsagePanel usage={answer!.item.turnUsage} />}
305 {(answer!.item.turnDurationMs ?? answer!.item.workDurationMs) != null && <TurnTimePanel
306 durationMs={(answer!.item.turnDurationMs ?? answer!.item.workDurationMs)!}
307 tokensPerSecond={answer!.item.tokensPerSecond}
308 />}
309 {answer!.item.createdAt != null && <time className="chat-actions__time" dateTime={new Date(answer!.item.createdAt).toISOString()}>{formatMessageClock(answer!.item.createdAt)}</time>}
310 {answer!.item.samplingCount !== undefined && <span>{t("chat.turnCounts", { samples: answer!.item.samplingCount, tools: answer!.item.toolCount ?? 0 })}</span>}
311 </div>}
312 </div>;
313 }
314
315 export function ChatDetails({ source, nodeKey, loader, onClose, onNavigate }: { source: ChatSource; nodeKey: string; loader: ChatContentLoader; onClose: () => void; onNavigate: (key: string) => void }) {
316 const node = useChatNode(source, nodeKey);
317 const subscribeChildren = useCallback((listener: () => void) => source.subscribeNode(`${nodeKey}:children`, listener), [source, nodeKey]);
318 const getChildren = useCallback(() => source.toolChildren(nodeKey), [source, nodeKey]);
319 const children = useSyncExternalStore(subscribeChildren, getChildren, getChildren);
320 const t = useT();
321 const [result, setResult] = useState<{ item: unknown; text: string }>();
322 const [busy, setBusy] = useState(false);
323 const [error, setError] = useState(false);
324 const [selectedTab, setSelectedTab] = useState<"result" | "parameters" | "children" | "raw">("result");
325 const [visibleChildren, setVisibleChildren] = useState<number>(TOOL_RELATION_PAGE_SIZE);
326 const root = useRef<HTMLElement>(null);
327 const epoch = useRef(0);
328 useEffect(() => { root.current?.focus(); return () => { epoch.current++; }; }, []);
329 useEffect(() => setVisibleChildren(TOOL_RELATION_PAGE_SIZE), [nodeKey]);
330 const load = async () => {
331 if (node?.kind !== "tool") throw new Error("Tool unavailable");
332 const ticket = ++epoch.current; setBusy(true); setError(false);
333 try { const text = await loader.load(node.item, "tool"); if (ticket === epoch.current) {
334 const current = source.getNodeSnapshot(nodeKey);
335 if (current?.kind !== "tool" || (current.item !== node.item && !matchesToolContent(current.item, text))) throw new Error("Tool content changed; retry");
336 setResult({ item: current.item, text });
337 return text;
338 } throw new Error("Detail closed"); }
339 catch (error) { if (ticket === epoch.current) setError(true); throw error; }
340 finally { if (ticket === epoch.current) setBusy(false); }
341 };
342 if (node?.kind !== "tool") return null;
343 const full = result && (result.item === node.item || matchesToolContent(node.item, result.text)) ? result.text : undefined;
344 const preview = JSON.stringify({ args: node.item.args, output: node.item.output, error: node.item.error, diff: node.item.fileDiff }, null, 2);
345 const hasResult = Boolean(node.item.output || node.item.error || node.item.fileDiff);
346 const hasParameters = Boolean(node.item.args.trim() && node.item.args.trim() !== "{}");
347 const hasRelations = Boolean(node.item.parentId || children.length);
348 const tabs = [hasResult && "result", hasParameters && "parameters", hasRelations && "children", "raw"].filter(Boolean) as Array<"result" | "parameters" | "children" | "raw">;
349 const activeTab = tabs.includes(selectedTab) ? selectedTab : tabs[0];
350 const title = classifyTool(node.item) === "shell" ? shellDisplayName(node.item) : node.item.resolvedName ?? node.item.name;
351 let formattedArgs = node.item.args;
352 try { formattedArgs = JSON.stringify(JSON.parse(node.item.args), null, 2); } catch { /* Streaming or unknown arguments stay copyable. */ }
353 const resultPreview = JSON.stringify({ output: node.item.output, error: node.item.error, diff: node.item.fileDiff }, null, 2);
354 let resultText = resultPreview;
355 if (full !== undefined) {
356 try {
357 const payload = JSON.parse(full) as Record<string, unknown>;
358 resultText = JSON.stringify({ output: payload.output, error: payload.error, diff: payload.diff }, null, 2);
359 } catch { resultText = full; }
360 }
361 return <aside ref={root} className="chat-details" role="dialog" aria-modal="true" aria-label={t("chat.details")} tabIndex={-1} onKeyDown={event => {
362 if (event.key === "Escape") { event.stopPropagation(); onClose(); }
363 if (event.key === "Tab") {
364 const buttons = Array.from(root.current?.querySelectorAll<HTMLElement>("button:not(:disabled), [href], [tabindex='0']") ?? []);
365 const first = buttons[0], last = buttons[buttons.length - 1];
366 if (event.shiftKey && (document.activeElement === first || document.activeElement === root.current)) { event.preventDefault(); last?.focus(); }
367 else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
368 }
369 }}>
370 <header><strong>{title}</strong><button className="btn" aria-label={t("common.close")} onClick={onClose}><X size={16} /></button></header>
371 <div className="chat-details__tabs" role="tablist" aria-label={t("chat.details")}>
372 {tabs.map(tab => <button key={tab} type="button" role="tab" aria-selected={activeTab === tab} onClick={() => setSelectedTab(tab)}>{t(`chat.details.${tab}`)}</button>)}
373 </div>
374 <div className="chat-details__body">
375 {(activeTab === "result" || activeTab === "raw") && full === undefined && <button className="btn" disabled={busy} onClick={() => void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
376 {(activeTab === "result" || activeTab === "raw") && <CopyButton getText={() => full === undefined ? load() : full} label={t("chat.copyFull")} />}
377 {activeTab === "result" && <Suspense fallback={<p role="status">{t("chat.loading")}</p>}><ToolPayload text={resultText} preview={full === undefined} /></Suspense>}
378 {activeTab === "parameters" && <pre>{formattedArgs}</pre>}
379 {activeTab === "children" && <div className="chat-details__relations">
380 {node.item.parentId && source.getNodeSnapshot(node.item.parentId)?.kind === "tool" && <button className="btn" onClick={() => onNavigate(node.item.parentId!)}>← {t("chat.details.parent")}</button>}
381 {children.slice(0, visibleChildren).map(child => <button className="chat-tool" key={child.key} onClick={() => onNavigate(child.key)}>{child.item.resolvedName ?? child.item.name} · {child.item.status}</button>)}
382 {children.length > visibleChildren && <button className="btn" data-testid="tool-children-more" onClick={() => setVisibleChildren(count => count + TOOL_RELATION_PAGE_SIZE)}>{t("chat.loadMoreTools", { count: Math.min(TOOL_RELATION_PAGE_SIZE, children.length - visibleChildren) })}</button>}
383 </div>}
384 {activeTab === "raw" && <><pre>{full ?? preview}</pre>{source.toolAudits(node.item.id).map((audit, index) => <pre key={index}>{audit}</pre>)}</>}
385 </div>
386 </aside>;
387 }
388
389 function matchesToolContent(item: Extract<ChatNode, { kind: "tool" }>["item"], text: string): boolean {
390 try { const value = JSON.parse(text); return value.args === item.args && value.output === item.output && value.error === item.error && JSON.stringify(value.diff) === JSON.stringify(item.fileDiff); }
391 catch { return false; }
392 }
393
394 export function ChatRunning({ source }: { source: ChatSource }) {
395 const status = useSyncExternalStore(source.subscribeStatus, source.getStatusSnapshot, source.getStatusSnapshot);
396 const t = useT();
397 const [now, setNow] = useState(Date.now);
398 useEffect(() => { if (!status.running) return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [status.running]);
399 return status.running ? <div className="chat-running" role="status">{t("chat.running")} {status.startedAt ? `${Math.max(0, Math.floor((now - status.startedAt) / 1000))}s` : ""}</div> : null;
400 }
401
401 lines Plain Text