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