返回 DeepSeek-Reasonix
ToolCard.tsx
根目录 / desktop / frontend / src / components / ToolCard.tsx
1 import { memo, useEffect, useRef, useState, type ReactNode } from "react";
2 import { ChevronRight, Compass } from "lucide-react";
3 import { CodeViewer } from "./CodeViewer";
4 import { DiffView } from "./DiffView";
5 import { useT } from "../lib/i18n";
6 import { diffsFor, languageForToolArgs, subjectOf, summarize, summarizeFileDiff } from "../lib/tools";
7 import { useShellExpand } from "../lib/shellExpand";
8 import { useGSAPCollapse } from "../lib/useGSAPCollapse";
9 import { isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
10 import type { Translator } from "../lib/i18n";
11 import { ReadOnlyBatch } from "./ReadOnlyBatch";
12
13 type ToolItem = Extract<Item, { kind: "tool" }>;
14
15 const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "review", "security_review"]);
16
17 function subagentPhaseLabel(t: Translator, phase: SubagentPhase): string {
18 switch (phase) {
19 case "queued": return t("subagent.phase.queued");
20 case "running": return t("subagent.phase.running");
21 case "reasoning": return t("subagent.phase.reasoning");
22 case "responding": return t("subagent.phase.responding");
23 case "tool": return t("subagent.phase.tool");
24 case "retrying": return t("subagent.phase.retrying");
25 case "completed": return t("subagent.phase.completed");
26 case "failed": return t("subagent.phase.failed");
27 case "cancelled": return t("subagent.phase.cancelled");
28 }
29 }
30
31 function formatElapsedSeconds(ms: number): string {
32 return String(Math.max(0, Math.round(ms / 1000)));
33 }
34
35 /** Lines shown by default in a shell output block before the "show all" button. */
36 const SHELL_PREVIEW_LINES = 10;
37 const ERROR_SUMMARY_MAX_CHARS = 140;
38 const ERROR_DETAILS_THRESHOLD = 220;
39
40 function pretty(json: string): string {
41 try {
42 return JSON.stringify(JSON.parse(json), null, 2);
43 } catch {
44 return json;
45 }
46 }
47
48 function formatToolDuration(ms?: number): string {
49 if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "";
50 return `${Math.round(ms)} ms`;
51 }
52
53 function shellDisplayName(execution?: { shell?: string; shellVersion?: string }): string {
54 switch (execution?.shell) {
55 case "git-bash":
56 return "Git Bash";
57 case "powershell":
58 return "Windows PowerShell";
59 case "pwsh":
60 return "PowerShell 7+";
61 case "bash":
62 return "bash";
63 default:
64 return execution?.shell || "bash";
65 }
66 }
67
68 function shellSettledSummary(
69 t: Translator,
70 execution: NonNullable<ToolItem["execution"]>,
71 durationMs?: number,
72 ): string {
73 const parts: string[] = [];
74 if (typeof execution.exitCode === "number") {
75 parts.push(t("tool.shell.exitCode", { code: execution.exitCode }));
76 }
77 if (execution.failurePhase) {
78 parts.push(execution.failurePhase);
79 }
80 const ms = execution.durationMs || durationMs;
81 if (typeof ms === "number" && Number.isFinite(ms) && ms >= 0) {
82 parts.push(formatToolDuration(ms));
83 }
84 return parts.join(" · ");
85 }
86
87 function shellVerificationLabel(t: Translator, verification?: string): string {
88 switch (verification) {
89 case "passed":
90 return t("tool.shell.verificationPassed");
91 case "failed":
92 return t("tool.shell.verificationFailed");
93 case "not_run":
94 return t("tool.shell.verificationNotRun");
95 default:
96 return "";
97 }
98 }
99
100 function shellRiskLabel(t: Translator, execution?: ToolItem["execution"]): string {
101 if (!execution) return "";
102 const phase = execution.failurePhase || "";
103 // Pre-run / not-started phases never touched disk.
104 if (phase === "preflight" || phase === "authorization" || phase === "dependency" || phase === "launch") {
105 return t("tool.shell.notExecuted");
106 }
107 // Backend marks failed, timed_out, and cancelled execution as may_be_partial
108 // when the process may already have written files. Show the warning for any
109 // such risk, not only state=failed.
110 if (execution.mutationRisk === "may_be_partial") {
111 return t("tool.shell.mayBePartial");
112 }
113 return "";
114 }
115
116 function firstTailLine(tail?: string): string {
117 if (!tail) return "";
118 const line = tail.replace(/\r\n/g, "\n").trim().split("\n")[0]?.trim() ?? "";
119 if (line.length <= ERROR_SUMMARY_MAX_CHARS) return line;
120 return `${line.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
121 }
122
123 function formatArgChars(chars: number): string {
124 if (chars >= 1000) return `${(chars / 1000).toFixed(1)}k`;
125 return String(chars);
126 }
127
128 function normalizeErrorText(text: string): string {
129 return text.replace(/\r\n/g, "\n").trim();
130 }
131
132 function withoutErrorPrefix(text: string): string {
133 return normalizeErrorText(text).replace(/^error:\s*/i, "");
134 }
135
136 function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
137 if (!output || !error) return false;
138 const normalizedOutput = normalizeErrorText(output);
139 const normalizedError = normalizeErrorText(error);
140 if (!normalizedOutput || !normalizedError) return false;
141 return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
142 }
143
144 function summarizeToolError(error: string, receiptMismatchText: string): string {
145 const text = withoutErrorPrefix(error);
146 if (!text) return "";
147 if (/has no matching successful receipt/i.test(text)) {
148 return receiptMismatchText;
149 }
150 const firstLine = text.split("\n")[0]?.trim() ?? "";
151 if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
152 return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
153 }
154
155 function errorNeedsDetails(error: string, summary: string): boolean {
156 const normalizedError = withoutErrorPrefix(error);
157 if (!normalizedError) return false;
158 return normalizedError.includes("\n") ||
159 normalizedError.length > ERROR_DETAILS_THRESHOLD ||
160 (summary !== "" && normalizedError !== summary);
161 }
162
163 /** Returns the first n lines of text and the total line count. */
164 function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
165 const lines = text.split("\n");
166 const total = lines.length;
167 if (total <= n) return { preview: text, total, hasMore: false };
168 return { preview: lines.slice(0, n).join("\n"), total, hasMore: true };
169 }
170
171 // ToolCard renders one tool call. `subcalls` are sub-agent calls nested under a
172 // `task` card (their ParentID points at this call); they render inline, live, so
173 // the sub-agent's work is visible as it happens.
174 export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayName }: { item: ToolItem; subcalls?: ToolItem[]; tabId?: string; displayName?: string }) {
175 const t = useT();
176 const nested = subcalls ?? [];
177 const hasNested = nested.length > 0;
178 const isSubagent = SUBAGENT_TOOLS.has(item.name);
179 const profileText =
180 isSubagent && item.profile
181 ? [item.profile.model, item.profile.effort ? `effort ${item.profile.effort}` : ""].filter(Boolean).join(" · ")
182 : "";
183
184 // Sub-agent progress chip: phase + running elapsed + recent activity. The
185 // 1s ticker only runs while a progress card is live; terminal cards show
186 // the final duration instead.
187 const sp = item.subagentProgress;
188 const [nowTick, setNowTick] = useState(() => Date.now());
189 useEffect(() => {
190 if (!sp || isTerminalSubagentPhase(sp.phase)) return;
191 const id = window.setInterval(() => setNowTick(Date.now()), 1000);
192 return () => window.clearInterval(id);
193 // eslint-disable-next-line react-hooks/exhaustive-deps
194 }, [sp]);
195 const subagentChip = sp
196 ? (() => {
197 const label = subagentPhaseLabel(t, sp.phase);
198 if (isTerminalSubagentPhase(sp.phase)) {
199 const ms = sp.durationMs ?? item.durationMs ?? 0;
200 return `${label} · ${t("subagent.phase.elapsed", { n: formatElapsedSeconds(ms) })}`;
201 }
202 return `${label} · ${t("subagent.phase.elapsed", { n: formatElapsedSeconds(nowTick - sp.startedAt) })} · ${t("subagent.activity.ago", { n: formatElapsedSeconds(nowTick - sp.lastActivityAt) })}`;
203 })()
204 : "";
205 const hasSubagentPreview = Boolean(sp && (sp.reasoning || sp.text || sp.notice));
206
207 // All tools default to collapsed. Sub-agent tools open while running so the
208 // user sees nested calls; they collapse when done. Reasoning (AssistantMessage)
209 // also opens while streaming and closes on finish.
210 const defaultOpen = hasNested ? item.status === "running" : false;
211 const [userOpen, setUserOpen] = useState<boolean | null>(null);
212 const open = userOpen ?? defaultOpen;
213 const openRef = useRef(open);
214 openRef.current = open;
215 const [showAll, setShowAll] = useState(false);
216 const [showErrorDetails, setShowErrorDetails] = useState(false);
217 // Lazy-load full tool data from the backend when the card is expanded and
218 // the in-memory copy was archived for memory efficiency.
219 const [fullData, setFullData] = useState<{ args: string; output?: string; execution?: ToolItem["execution"] } | null>(null);
220 const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
221 const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
222 const effectiveOutput = fullData?.output ?? item.output;
223 const execution = fullData?.execution ?? item.execution;
224 const isShellCard = Boolean(item.isShell || item.name === "bash" || execution);
225 const displayOutput = toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
226 const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
227 const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
228 const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
229 const shellName = isShellCard ? shellDisplayName(execution) : (displayName ?? item.name);
230 const shellSummary = execution && item.status !== "running" ? shellSettledSummary(t, execution, item.durationMs) : "";
231 const verificationLabel = shellVerificationLabel(t, execution?.verification);
232 const riskLabel = shellRiskLabel(t, execution);
233 const tailSummary = firstTailLine(execution?.outputTail);
234 // Reset cached fullData when the item identity changes (e.g. after rewind).
235 useEffect(() => {
236 return () => setFullData(null);
237 }, [item]);
238
239 // edit diffs are the point of the card, so they're shown inline; everything
240 // else folds its args/output away by default. Open while running so the
241 // user sees progress; closed by default once settled.
242 const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
243 const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!displayOutput || hasArchivedOnDemandBody);
244
245 // Shell output: split into preview + "show all" toggle.
246 const shellOutput = isShellCard && displayOutput ? displayOutput : null;
247 const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
248 const hasStderrDetails = Boolean(execution?.outputTail && execution.outputTail.trim());
249 const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error || hasSubagentPreview || hasStderrDetails || riskLabel || verificationLabel);
250 const errorText = item.error ? normalizeErrorText(item.error) : "";
251 const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
252 const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
253 useEffect(() => {
254 if (!open || !item.dataArchived || fullData || !tabId) return;
255 let cancelled = false;
256 import("../lib/bridge").then(({ app }) =>
257 app.ToolResultForTab(tabId, item.id).then((d) => {
258 if (!cancelled && d) setFullData(d);
259 }).catch(() => {}),
260 ).catch(() => {});
261 return () => { cancelled = true; };
262 }, [open, item.id, item.dataArchived, fullData, tabId]);
263
264 // Register this shell card's toggle with the global ShellExpand context so
265 // Ctrl/Cmd+B can expand/collapse the most recent shell output. openRef keeps the
266 // registered closure flipping the current state, not a stale one.
267 const shellExpand = useShellExpand();
268 useEffect(() => {
269 if (!isShellCard || !shellExpand) return;
270 return shellExpand.register(item.id, () => setUserOpen(!openRef.current));
271 }, [isShellCard, item.id, shellExpand]);
272
273 // Read-only "research" calls (read/grep/ls/glob/web_fetch) are hidden after
274 // completion so they don't clutter the transcript. During execution they still
275 // render so the user sees progress.
276 const quiet =
277 item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
278
279 const duration = item.status === "running" ? "" : (shellSummary || formatToolDuration(item.durationMs));
280 // While the model is still streaming this call's arguments (partial
281 // dispatch), show the received volume as the live subject so a long
282 // write_file body reads as progress instead of a silent stall.
283 const streamingArgs = item.status === "running" && !item.args && (item.argChars ?? 0) > 0
284 ? t("tool.receivingArgs", { chars: formatArgChars(item.argChars ?? 0) })
285 : "";
286 const summary = item.status === "running"
287 ? streamingArgs
288 : (verificationLabel || item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? (tailSummary || errorSummary) : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error)));
289 const a11yLabel = isShellCard
290 ? `${shellName} ${item.status}${shellSummary || summary ? ` ${shellSummary || summary}` : ""}`
291 : undefined;
292
293 // GSAP-driven collapse/expand for tool body
294 const toolBodyRef = useRef<HTMLDivElement>(null);
295 useGSAPCollapse(toolBodyRef, open);
296
297 return (
298 <div className={`tool${quiet ? " tool--quiet" : ""}${isSubagent ? " tool--subagent" : ""}${open && hasBody ? " tool--open" : ""}`} data-entrance={item.id} data-shell={isShellCard ? execution?.shell || "bash" : undefined}>
299 <button
300 type="button"
301 className="tool__head"
302 data-running={item.status === "running" ? "" : undefined}
303 onClick={() => hasBody && setUserOpen(!open)}
304 aria-expanded={hasBody ? open : undefined}
305 aria-label={a11yLabel}
306 >
307 <span className="tool__label-group">
308 {hasNested && (
309 <span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
310 <Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
311 <span>{nested.length}</span>
312 </span>
313 )}
314 {item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
315 {item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
316 {item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
317 <span className="tool__name">{isShellCard ? shellName : (displayName ?? item.name)}</span>
318 {subject && <span className="tool__subject">{subject}</span>}
319 </span>
320 {profileText && <span className="tool__profile">{profileText}</span>}
321 {subagentChip && (
322 <span className={`tool__subagent-chip tool__subagent-chip--${sp?.phase}`} data-phase={sp?.phase}>
323 <span className="tool__subagent-dot" aria-hidden="true" />
324 {subagentChip}
325 </span>
326 )}
327 {summary && <span className="tool__summary">{summary}</span>}
328 {duration && <span className="tool__duration">{duration}</span>}
329 {hasBody && (
330 <span className={`tool__chevron${open ? " tool__chevron--open" : ""}`}>
331 <ChevronRight size={12} />
332 </span>
333 )}
334 {item.status !== "running" && (
335 <span
336 className={`tool__dot${item.status === "done" ? " tool__dot--ok" : ""}${item.status === "error" ? " tool__dot--err" : ""}${item.status === "stopped" ? " tool__dot--stopped" : ""}`}
337 aria-hidden="true"
338 />
339 )}
340 </button>
341
342 <div ref={toolBodyRef} className="tool__body">
343
344 {previewDiff ? (
345 <DiffView diff={previewDiff.diff} language={languageForToolArgs(fullData?.args ?? item.args)} maxHeight={260} />
346 ) : (
347 diffs.map((d, i) => (
348 <div key={i}>
349 {d.label && <div className="tool__difflabel">{d.label}</div>}
350 <DiffView original={d.original} modified={d.modified} language={d.lang} maxHeight={260} />
351 </div>
352 ))
353 )}
354
355 {hasSubagentPreview && sp && (
356 <div className="tool__subagent-preview">
357 {sp.reasoning && (
358 <div className="tool__subagent-preview-section">
359 <div className="tool__subagent-preview-label">{t("subagent.preview.reasoning")}</div>
360 <pre className="tool__subagent-preview-text">{sp.reasoning}</pre>
361 </div>
362 )}
363 {sp.text && (
364 <div className="tool__subagent-preview-section">
365 <div className="tool__subagent-preview-label">{t("subagent.preview.text")}</div>
366 <pre className="tool__subagent-preview-text">{sp.text}</pre>
367 </div>
368 )}
369 {sp.notice && (
370 <div className="tool__subagent-preview-section">
371 <div className="tool__subagent-preview-label">{t("subagent.preview.notice")}</div>
372 <pre className="tool__subagent-preview-text">{sp.notice}</pre>
373 </div>
374 )}
375 {sp.truncated && <div className="tool__note">{t("subagent.preview.truncated")}</div>}
376 </div>
377 )}
378
379 {hasNested && (
380 <div className="tool__nested">
381 {(() => {
382 const out: ReactNode[] = [];
383 const roBatch: typeof nested = [];
384 const flush = () => {
385 if (roBatch.length === 0) return;
386 out.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={new Map()} tabId={tabId} />);
387 roBatch.length = 0;
388 };
389 for (const c of nested) {
390 if (c.readOnly && c.name !== "todo_write") {
391 roBatch.push(c);
392 continue;
393 }
394 flush();
395 out.push(<ToolCard key={c.id} item={c} tabId={tabId} />);
396 }
397 flush();
398 return out;
399 })()}
400 </div>
401 )}
402
403 {isShellCard && (riskLabel || verificationLabel) && (
404 <div className="tool__note" role="status">
405 {[riskLabel, verificationLabel].filter(Boolean).join(" · ")}
406 </div>
407 )}
408
409 {shellPreview && (
410 <>
411 <CodeViewer value={showAll ? shellOutput! : shellPreview.preview} maxHeight={showAll ? 480 : 260} />
412 {shellPreview.hasMore && !showAll && (
413 <button className="tool__showall" onClick={() => setShowAll(true)}>
414 {t("tool.showAllLines", { n: shellPreview.total })}
415 </button>
416 )}
417 {item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
418 </>
419 )}
420
421 {hasStderrDetails && (
422 <details className="tool__error-details">
423 <summary>{tailSummary || t("tool.showErrorDetails")}</summary>
424 <CodeViewer value={execution!.outputTail!} maxHeight={240} />
425 </details>
426 )}
427
428 {!shellPreview && hasArgsOrOutput && (
429 <>
430 {effectiveArgs && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
431 {displayOutput && (
432 <>
433 <CodeViewer value={displayOutput} maxHeight={280} />
434 {item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
435 </>
436 )}
437 </>
438 )}
439
440 {errorText && (
441 <div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
442 {hasErrorDetails ? (
443 <>
444 <div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
445 <button
446 type="button"
447 className="tool__err-toggle"
448 onClick={() => setShowErrorDetails((value) => !value)}
449 aria-expanded={showErrorDetails}
450 >
451 <ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
452 <span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
453 </button>
454 {showErrorDetails && <div className="tool__err-details">{errorText}</div>}
455 </>
456 ) : (
457 errorText
458 )}
459 </div>
460 )}
461 </div>
462 </div>
463 );
464 });
465
465 lines Plain Text