返回 DeepSeek-Reasonix
ToolCard.tsx
根目录 / desktop / frontend / src / components / ToolCard.tsx
1 import { isShellToolName } from "../lib/shellToolIdentity";
2 import { searchOutputMetadata } from "../lib/searchSources";
3 import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
4 import { Suspense, lazy } from "react";
5 import { ChevronRight, Compass } from "lucide-react";
6 import { CodeViewer } from "./CodeViewer";
7 import { DiffView } from "./DiffView";
8 import { useT } from "../lib/i18n";
9 import { diffsFor, languageForToolArgs, subjectOf, summarize, summarizeFileDiff } from "../lib/tools";
10 import { useShellExpand } from "../lib/shellExpand";
11 import { app } from "../lib/bridge";
12 import type { MCPAppInstanceView, MCPAppPresentation } from "../lib/types";
13
14 const MCPAppCard = lazy(() => import("./MCPAppCard").then((m) => ({ default: m.MCPAppCard })));
15 const SubagentOutcomeCard = lazy(() => import("./SubagentOutcomeCard").then((m) => ({ default: m.SubagentOutcomeCard })));
16 const SubagentPreview = lazy(() => import("./SubagentPreview").then((m) => ({ default: m.SubagentPreview })));
17
18 function MCPAppCardLazy({
19 instance,
20 presentation,
21 toolArgs,
22 toolOutput,
23 onDispose,
24 }: {
25 instance: MCPAppInstanceView;
26 presentation: MCPAppPresentation;
27 toolArgs: string;
28 toolOutput?: string;
29 onDispose: (instanceToken: string) => void;
30 }) {
31 return (
32 <Suspense fallback={null}>
33 <MCPAppCard
34 instance={instance}
35 presentation={presentation}
36 toolArgs={toolArgs}
37 toolOutput={toolOutput}
38 onDispose={onDispose}
39 />
40 </Suspense>
41 );
42 }
43 import { useCollapseAnimation } from "../lib/useCollapseAnimation";
44 import { isBatchedReadOnlyTool, isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
45 import type { Translator } from "../lib/i18n";
46 import { ReadOnlyBatch } from "./ReadOnlyBatch";
47 import { useWorkProcessPresentation } from "../lib/sessionExperience";
48 import { resolveToolCardDefaultOpen } from "../lib/toolCardDisclosure";
49 import { useArchivedToolData } from "../lib/useArchivedToolData";
50 import type { SearchSourcePresentation } from "../lib/searchSourcesPresentation";
51
52 type ToolItem = Extract<Item, { kind: "tool" }>;
53
54 function commandLanguage(shell: string | undefined, name: string): string | undefined {
55 const knownShell = shell?.toLowerCase() || (isShellToolName(name) ? name.toLowerCase() : "");
56 if (knownShell === "powershell" || knownShell === "pwsh") return "powershell";
57 if (knownShell === "bash" || knownShell === "sh" || knownShell === "zsh") return "bash";
58 return undefined;
59 }
60
61 const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "review", "security_review"]);
62
63 function subagentPhaseLabel(t: Translator, phase: SubagentPhase): string {
64 switch (phase) {
65 case "queued": return t("subagent.phase.queued");
66 case "running": return t("subagent.phase.running");
67 case "reasoning": return t("subagent.phase.reasoning");
68 case "responding": return t("subagent.phase.responding");
69 case "tool": return t("subagent.phase.tool");
70 case "retrying": return t("subagent.phase.retrying");
71 case "completed": return t("subagent.phase.completed");
72 case "partial": return t("subagent.phase.partial");
73 case "failed": return t("subagent.phase.failed");
74 case "cancelled": return t("subagent.phase.cancelled");
75 }
76 }
77
78 function formatElapsedSeconds(ms: number): string {
79 return String(Math.max(0, Math.round(ms / 1000)));
80 }
81
82 function formatRunningElapsed(ms: number): string {
83 const seconds = Number(formatElapsedSeconds(ms));
84 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`;
85 }
86
87 /** Lines shown by default in a shell output block before the "show all" button. */
88 const SHELL_PREVIEW_LINES = 10;
89 const ERROR_SUMMARY_MAX_CHARS = 140;
90 const ERROR_DETAILS_THRESHOLD = 220;
91
92 function pretty(json: string): string {
93 try {
94 return JSON.stringify(JSON.parse(json), null, 2);
95 } catch {
96 return json;
97 }
98 }
99
100 function formatToolDuration(ms?: number): string {
101 if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "";
102 return `${Math.round(ms)} ms`;
103 }
104
105 function shellDisplayName(execution: { shell?: string; shellVersion?: string } | undefined, toolName: string): string {
106 const shell = execution?.shell || (isShellToolName(toolName) ? toolName.trim().toLowerCase() : "");
107 switch (shell) {
108 case "git-bash":
109 return "Git Bash";
110 case "powershell":
111 return "Windows PowerShell";
112 case "pwsh":
113 return "PowerShell 7+";
114 case "bash":
115 return "bash";
116 default:
117 return shell || "bash";
118 }
119 }
120
121 function shellSettledSummary(
122 t: Translator,
123 execution: NonNullable<ToolItem["execution"]>,
124 durationMs?: number,
125 ): string {
126 const parts: string[] = [];
127 if (typeof execution.exitCode === "number") {
128 parts.push(t("tool.shell.exitCode", { code: execution.exitCode }));
129 }
130 if (execution.failurePhase) {
131 parts.push(execution.failurePhase);
132 }
133 const ms = execution.durationMs || durationMs;
134 if (typeof ms === "number" && Number.isFinite(ms) && ms >= 0) {
135 parts.push(formatToolDuration(ms));
136 }
137 return parts.join(" · ");
138 }
139
140 function shellVerificationLabel(t: Translator, verification?: string): string {
141 switch (verification) {
142 case "passed":
143 return t("tool.shell.verificationPassed");
144 case "failed":
145 return t("tool.shell.verificationFailed");
146 case "not_run":
147 return t("tool.shell.verificationNotRun");
148 default:
149 return "";
150 }
151 }
152
153 function shellRiskLabel(t: Translator, execution?: ToolItem["execution"]): string {
154 if (!execution) return "";
155 const phase = execution.failurePhase || "";
156 // Pre-run / not-started phases never touched disk.
157 if (phase === "preflight" || phase === "authorization" || phase === "dependency" || phase === "launch") {
158 return t("tool.shell.notExecuted");
159 }
160 // Backend marks failed, timed_out, and cancelled execution as may_be_partial
161 // when the process may already have written files. Show the warning for any
162 // such risk, not only state=failed.
163 if (execution.mutationRisk === "may_be_partial") {
164 return t("tool.shell.mayBePartial");
165 }
166 return "";
167 }
168
169 function firstTailLine(tail?: string): string {
170 if (!tail) return "";
171 const line = tail.replace(/\r\n/g, "\n").trim().split("\n")[0]?.trim() ?? "";
172 if (line.length <= ERROR_SUMMARY_MAX_CHARS) return line;
173 return `${line.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
174 }
175
176 function formatArgChars(chars: number): string {
177 if (chars >= 1000) return `${(chars / 1000).toFixed(1)}k`;
178 return String(chars);
179 }
180
181 function normalizeErrorText(text: string): string {
182 return text.replace(/\r\n/g, "\n").trim();
183 }
184
185 function withoutErrorPrefix(text: string): string {
186 return normalizeErrorText(text).replace(/^error:\s*/i, "");
187 }
188
189 function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
190 if (!output || !error) return false;
191 const normalizedOutput = normalizeErrorText(output);
192 const normalizedError = normalizeErrorText(error);
193 if (!normalizedOutput || !normalizedError) return false;
194 return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
195 }
196
197 function summarizeToolError(error: string, receiptMismatchText: string): string {
198 const text = withoutErrorPrefix(error);
199 if (!text) return "";
200 if (/has no matching successful receipt/i.test(text)) {
201 return receiptMismatchText;
202 }
203 const firstLine = text.split("\n")[0]?.trim() ?? "";
204 if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
205 return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
206 }
207
208 function errorNeedsDetails(error: string, summary: string): boolean {
209 const normalizedError = withoutErrorPrefix(error);
210 if (!normalizedError) return false;
211 return normalizedError.includes("\n") ||
212 normalizedError.length > ERROR_DETAILS_THRESHOLD ||
213 (summary !== "" && normalizedError !== summary);
214 }
215
216 /** Returns the first n lines of text and the total line count. */
217 function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
218 const lines = text.split("\n");
219 const total = lines.length;
220 if (total <= n) return { preview: text, total, hasMore: false };
221 return { preview: lines.slice(0, n).join("\n"), total, hasMore: true };
222 }
223
224 // ToolCard renders one tool call. `subcalls` are sub-agent calls nested under a
225 // `task` card (their ParentID points at this call); they render inline, live, so
226 // the sub-agent's work is visible as it happens.
227 export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayName }: { item: ToolItem; subcalls?: ToolItem[]; tabId?: string; displayName?: string }) {
228 const t = useT();
229 const nested = subcalls ?? [];
230 const hasNested = nested.length > 0;
231 const isSubagent = SUBAGENT_TOOLS.has(item.name);
232 const profileText =
233 isSubagent && item.profile
234 ? [item.profile.model, item.profile.effort ? `effort ${item.profile.effort}` : ""].filter(Boolean).join(" · ")
235 : "";
236
237 // One 1s ticker per live card feeds both the sub-agent chip and the plain
238 // running-elapsed label; terminal cards show the final duration instead.
239 const sp = item.subagentProgress;
240 const ticking = sp ? !isTerminalSubagentPhase(sp.phase) : item.status === "running" && item.startedAt !== undefined;
241 const [nowTick, setNowTick] = useState(() => Date.now());
242 useEffect(() => {
243 if (!ticking) return;
244 const id = window.setInterval(() => setNowTick(Date.now()), 1000);
245 return () => window.clearInterval(id);
246 }, [ticking]);
247 const liveElapsed = ticking && !sp && item.startedAt !== undefined ? formatRunningElapsed(nowTick - item.startedAt) : "";
248 const subagentChip = sp
249 ? (() => {
250 const label = subagentPhaseLabel(t, sp.phase);
251 if (isTerminalSubagentPhase(sp.phase)) {
252 const ms = sp.durationMs ?? item.durationMs ?? 0;
253 return `${label} · ${t("subagent.phase.elapsed", { n: formatElapsedSeconds(ms) })}`;
254 }
255 return `${label} · ${t("subagent.phase.elapsed", { n: formatElapsedSeconds(nowTick - sp.startedAt) })} · ${t("subagent.activity.ago", { n: formatElapsedSeconds(nowTick - sp.lastActivityAt) })}`;
256 })()
257 : "";
258 const presentation = useWorkProcessPresentation();
259 const hasSubagentPreview = Boolean(sp && ((sp.reasoning && presentation.showWhileRunning) || sp.text || sp.notice));
260
261 // All tools default to collapsed. Sub-agent tools open while running so the
262 // user sees nested calls; they collapse when done. Reasoning (AssistantMessage)
263 // stays open for the same owner lifecycle instead of collapsing between the
264 // reasoning and response/tool phases.
265 const subagentReasoningRunning = sp?.phase === "reasoning";
266 const subagentActive = Boolean(sp) && item.status === "running";
267 const liveFollow = presentation.showWhileRunning;
268 const defaultOpen = resolveToolCardDefaultOpen(item, nested.length, presentation);
269 const [userOpen, setUserOpen] = useState<boolean | null>(null);
270 const open = userOpen ?? defaultOpen;
271 const openRef = useRef(open);
272 openRef.current = open;
273 const [showAll, setShowAll] = useState(false);
274 const [showErrorDetails, setShowErrorDetails] = useState(false);
275 // The sub-agent reasoning preview opens as a one-line summary; the full
276 // Markdown only mounts after the user expands the reasoning section.
277 const [subagentReasoningOpen, setSubagentReasoningOpen] = useState(
278 () => presentation.keepExpandedAfterCompletion || (presentation.showWhileRunning && subagentActive),
279 );
280 const subagentReasoningUserOverridden = useRef(false);
281 const previousSubagentReasoningRunning = useRef(subagentReasoningRunning);
282 const previousSubagentActive = useRef(subagentActive);
283 const previousExperience = useRef(presentation.experience);
284 useEffect(() => {
285 const modeChanged = previousExperience.current !== presentation.experience;
286 const wasRunning = previousSubagentReasoningRunning.current;
287 const wasActive = previousSubagentActive.current;
288 previousExperience.current = presentation.experience;
289 previousSubagentReasoningRunning.current = subagentReasoningRunning;
290 previousSubagentActive.current = subagentActive;
291 if (modeChanged) {
292 subagentReasoningUserOverridden.current = false;
293 setSubagentReasoningOpen(presentation.keepExpandedAfterCompletion || (presentation.showWhileRunning && subagentActive));
294 return;
295 }
296 if ((subagentActive && !wasActive) || (subagentReasoningRunning && !wasRunning)) {
297 subagentReasoningUserOverridden.current = false;
298 if (liveFollow) setSubagentReasoningOpen(true);
299 return;
300 }
301 if (!presentation.showWhileRunning) return;
302 if (!subagentActive && wasActive && !presentation.keepExpandedAfterCompletion && !subagentReasoningUserOverridden.current) {
303 setSubagentReasoningOpen(false);
304 }
305 }, [liveFollow, presentation, subagentActive, subagentReasoningRunning]);
306 // Lazy-load full tool data from the backend when the card is expanded and
307 // the in-memory copy was archived for memory efficiency.
308 const { data: fullData, loading: fullDataLoading, failed: fullDataFailed, retry: retryFullData } = useArchivedToolData(item, tabId, open);
309 const [appInstance, setAppInstance] = useState<MCPAppInstanceView | null>(null);
310 const disposeAppInstance = useCallback((instanceToken: string) => {
311 setAppInstance((current) => current?.instanceToken === instanceToken ? null : current);
312 }, []);
313 const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
314 const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
315 const effectiveOutput = fullData?.output ?? item.output;
316 const execution = fullData?.execution ?? item.execution;
317 const isWebSearch = item.name === "web_search";
318 const [searchPresentation, setSearchPresentation] = useState<SearchSourcePresentation | null>(null);
319 useEffect(() => {
320 let cancelled = false;
321 if (!isWebSearch) { setSearchPresentation(null); return () => { cancelled = true; }; }
322 void import("../lib/searchSourcesPresentation").then(({ normalizeSearchSources }) => {
323 if (!cancelled) setSearchPresentation(normalizeSearchSources(item.searchSources));
324 });
325 return () => { cancelled = true; };
326 }, [isWebSearch, item.searchSources]);
327 const searchVisibleCount = searchPresentation?.visible.length ?? item.searchSources?.length ?? 0;
328 const searchHiddenCount = searchPresentation?.hiddenCount ?? 0;
329 const searchMetadata = searchOutputMetadata(effectiveOutput);
330 const searchSummary = searchMetadata.summary ?? item.searchSummary;
331 const searchSourcesMissing = (item.searchSourcesStatus ?? searchMetadata.status) === "not_provided";
332 const searchResultLabel = searchSourcesMissing ? t("sources.notProvided") : isWebSearch && searchVisibleCount === 0 && searchHiddenCount > 0
333 ? t("sources.noValid")
334 : t("tool.searchResults", { n: searchVisibleCount });
335 const isShellCard = Boolean(item.isShell || isShellToolName(item.name) || execution);
336 const shellCommand = isShellCard ? subjectOf("bash", effectiveArgs) : "";
337 const displayOutput = isWebSearch || toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
338 const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
339 const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
340 const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
341 const shellName = isShellCard ? shellDisplayName(execution, item.name) : (displayName ?? item.name);
342 const shellSummary = execution && item.status !== "running" ? shellSettledSummary(t, execution, item.durationMs) : "";
343 const verificationLabel = shellVerificationLabel(t, execution?.verification);
344 const riskLabel = shellRiskLabel(t, execution);
345 const tailSummary = firstTailLine(execution?.outputTail);
346 // An MCP app instance must not outlive the payload identity that created it.
347 useEffect(() => {
348 return () => setAppInstance(null);
349 }, [item, tabId]);
350
351 // edit diffs are the point of the card, so they're shown inline; everything
352 // else folds its args/output away by default. Open while running so the
353 // user sees progress; closed by default once settled.
354 const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
355 const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (isWebSearch
356 ? Boolean(effectiveArgs || searchVisibleCount || searchHiddenCount || searchSourcesMissing || searchSummary || hasArchivedOnDemandBody)
357 : Boolean(effectiveArgs || displayOutput || hasArchivedOnDemandBody));
358
359 // Shell output: split into preview + "show all" toggle.
360 const shellOutput = isShellCard && displayOutput ? displayOutput : null;
361 const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
362 const hasStderrDetails = Boolean(execution?.outputTail && execution.outputTail.trim());
363 const hasSubagentOutcome = Boolean(item.subagentOutcome || effectiveOutput?.includes("Subagent outcome:"));
364 const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error || hasSubagentPreview || hasSubagentOutcome || hasStderrDetails || riskLabel || verificationLabel);
365 const errorText = item.error ? normalizeErrorText(item.error) : "";
366 const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
367 const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
368 useEffect(() => {
369 if (!open) setAppInstance(null);
370 }, [open, item.id]);
371
372 // Register this shell card's toggle with the global ShellExpand context so
373 // Ctrl/Cmd+B can expand/collapse the most recent shell output. openRef keeps the
374 // registered closure flipping the current state, not a stale one.
375 const shellExpand = useShellExpand();
376 useEffect(() => {
377 if (!isShellCard || !shellExpand) return;
378 return shellExpand.register(item.id, () => setUserOpen(!openRef.current));
379 }, [isShellCard, item.id, shellExpand]);
380
381 // Read-only "research" calls (read/grep/ls/glob/web_fetch) are hidden after
382 // completion so they don't clutter the transcript. During execution they still
383 // render so the user sees progress.
384 const quiet =
385 item.readOnly && item.name !== "web_search" && !hasNested && item.status !== "error" && item.status !== "stopped";
386
387 const duration = item.status === "running" ? liveElapsed : (shellSummary || formatToolDuration(item.durationMs));
388 // While the model is still streaming this call's arguments (partial
389 // dispatch), show the received volume as the live subject so a long
390 // write_file body reads as progress instead of a silent stall.
391 const streamingArgs = item.status === "running" && !item.args && (item.argChars ?? 0) > 0
392 ? t("tool.receivingArgs", { chars: formatArgChars(item.argChars ?? 0) })
393 : "";
394 const summary = item.status === "running"
395 ? streamingArgs
396 : (isWebSearch
397 ? (item.error ? (tailSummary || errorSummary) : searchResultLabel)
398 : (verificationLabel || item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? (tailSummary || errorSummary) : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error))));
399 const a11yLabel = isShellCard
400 ? `${shellName} ${item.status}${shellSummary || summary ? ` ${shellSummary || summary}` : ""}`
401 : undefined;
402
403 // Native collapse/expand for the tool body.
404 const toolBodyRef = useRef<HTMLDivElement>(null);
405 useCollapseAnimation(toolBodyRef, open);
406
407 return (
408 <div
409 className={`tool${quiet ? " tool--quiet" : ""}${isSubagent ? " tool--subagent" : ""}${open && hasBody ? " tool--open" : ""}`}
410 data-entrance={item.id}
411 data-shell={isShellCard ? execution?.shell || "bash" : undefined}
412 data-transcript-layout-variant={open && hasBody ? "tool-expanded" : "tool-collapsed"}
413 >
414 <button
415 type="button"
416 className="tool__head"
417 data-running={item.status === "running" ? "" : undefined}
418 onClick={() => { if (hasBody) { setUserOpen(!open); } }}
419 aria-expanded={hasBody ? open : undefined}
420 aria-label={a11yLabel}
421 >
422 <span className="tool__label-group">
423 {hasNested && (
424 <span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
425 <Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
426 <span>{nested.length}</span>
427 </span>
428 )}
429 {item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
430 {item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
431 {item.status === "unknown" && <span className="tool__status-icon" title={t("tool.statusUnknown")}>?</span>}
432 {item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
433 <span className="tool__name">{isShellCard ? shellName : (displayName ?? item.name)}</span>
434 {subject && <span className="tool__subject">{subject}</span>}
435 </span>
436 {profileText && <span className="tool__profile">{profileText}</span>}
437 {subagentChip && (
438 <span className={`tool__subagent-chip tool__subagent-chip--${sp?.phase}`} data-phase={sp?.phase}>
439 <span className="tool__subagent-dot" aria-hidden="true" />
440 {subagentChip}
441 </span>
442 )}
443 {summary && <span className="tool__summary">{summary}</span>}
444 {duration && <span className="tool__duration">{duration}</span>}
445 {hasBody && (
446 <span className={`tool__chevron${open ? " tool__chevron--open" : ""}`}>
447 <ChevronRight size={12} />
448 </span>
449 )}
450 {item.status !== "running" && (
451 <span
452 className={`tool__dot${item.status === "done" ? " tool__dot--ok" : ""}${item.status === "error" ? " tool__dot--err" : ""}${item.status === "stopped" ? " tool__dot--stopped" : ""}`}
453 aria-hidden="true"
454 />
455 )}
456 </button>
457
458 <div ref={toolBodyRef} className="tool__body">
459
460 {open && (fullDataLoading || fullDataFailed) && (
461 <div className="tool__data-status" role={fullDataFailed ? "alert" : "status"}>
462 <span>{t(fullDataFailed ? "tool.loadFailed" : "common.loading")}</span>
463 {fullDataFailed && tabId && <button type="button" className="btn btn--small" onClick={() => { retryFullData(); }}>{t("common.retry")}</button>}
464 </div>
465 )}
466
467 {previewDiff ? (
468 <DiffView diff={previewDiff.diff} language={languageForToolArgs(fullData?.args ?? item.args)} maxHeight={260} />
469 ) : (
470 diffs.map((d, i) => (
471 <div key={i}>
472 {d.label && <div className="tool__difflabel">{d.label}</div>}
473 <DiffView original={d.original} modified={d.modified} language={d.lang} maxHeight={260} />
474 </div>
475 ))
476 )}
477
478 {open && hasSubagentPreview && sp && (
479 <Suspense fallback={null}>
480 <SubagentPreview
481 progress={sp}
482 showReasoning={presentation.showWhileRunning}
483 reasoningOpen={subagentReasoningOpen}
484 onReasoningToggle={() => {
485
486 subagentReasoningUserOverridden.current = true;
487 const next = !subagentReasoningOpen;
488 if (next) setUserOpen(true);
489 setSubagentReasoningOpen(next);
490 }}
491 onReasoningOpen={() => {
492
493 subagentReasoningUserOverridden.current = true;
494 setUserOpen(true);
495 setSubagentReasoningOpen(true);
496 }}
497 />
498 </Suspense>
499 )}
500
501 {open && hasSubagentOutcome && (
502 <Suspense fallback={null}>
503 <SubagentOutcomeCard
504 text={effectiveOutput}
505 outcome={item.subagentOutcome}
506 />
507 </Suspense>
508 )}
509
510 {hasNested && (
511 <div className="tool__nested">
512 {(() => {
513 const out: ReactNode[] = [];
514 const roBatch: typeof nested = [];
515 const flush = () => {
516 if (roBatch.length === 0) return;
517 out.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={new Map()} tabId={tabId} />);
518 roBatch.length = 0;
519 };
520 for (const c of nested) {
521 if (isBatchedReadOnlyTool(c.name, c.readOnly)) {
522 roBatch.push(c);
523 continue;
524 }
525 flush();
526 out.push(<ToolCard key={c.id} item={c} tabId={tabId} />);
527 }
528 flush();
529 return out;
530 })()}
531 </div>
532 )}
533
534 {isShellCard && (riskLabel || verificationLabel) && (
535 <div className="tool__note" role="status">
536 {[riskLabel, verificationLabel].filter(Boolean).join(" · ")}
537 </div>
538 )}
539
540 {open && shellCommand && (
541 <div className="tool__command">
542 <div className="tool__command-label">{t("tool.command")}</div>
543 <CodeViewer value={shellCommand} language={commandLanguage(execution?.shell, item.name)} maxHeight={240} />
544 </div>
545 )}
546
547 {shellPreview && (
548 <>
549 <CodeViewer value={showAll ? shellOutput! : shellPreview.preview} maxHeight={showAll ? 480 : 260} />
550 {shellPreview.hasMore && !showAll && (
551 <button className="tool__showall" onClick={() => { setShowAll(true); }}>
552 {t("tool.showAllLines", { n: shellPreview.total })}
553 </button>
554 )}
555 {item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
556 </>
557 )}
558
559 {hasStderrDetails && (
560 <details className="tool__error-details">
561 <summary>{tailSummary || t("tool.showErrorDetails")}</summary>
562 <CodeViewer value={execution!.outputTail!} maxHeight={240} />
563 </details>
564 )}
565
566 {isWebSearch && hasArgsOrOutput && (
567 <div className="tool__search-summary">
568 {subject && <div className="tool__search-query">{t("tool.searchQuery", { query: subject })}</div>}
569 {searchSummary && <div className="tool__search-summary-text">{searchSummary}</div>}
570 <div className="tool__search-count">
571 {searchResultLabel}
572 {searchHiddenCount > 0 && ` · ${t("sources.hidden", { n: searchHiddenCount })}`}
573 </div>
574 </div>
575 )}
576
577 {!isWebSearch && hasArgsOrOutput && (
578 <>
579 {effectiveArgs && !shellCommand && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
580 {!shellPreview && displayOutput && (
581 <>
582 <CodeViewer value={displayOutput} maxHeight={280} />
583 {item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
584 </>
585 )}
586 </>
587 )}
588
589 {open && tabId && fullData?.mcpApp?.resourceUri && (
590 <div className="tool__mcp-app">
591 {appInstance ? (
592 <MCPAppCardLazy
593 instance={appInstance}
594 presentation={fullData.mcpApp}
595 toolArgs={fullData.args}
596 toolOutput={fullData.output}
597 onDispose={disposeAppInstance}
598 />
599 ) : (
600 <button
601 type="button"
602 className="tool__mcp-app-open"
603 onClick={() => {
604 const mcpApp = fullData?.mcpApp as MCPAppPresentation | undefined;
605 if (!mcpApp?.resourceUri) return;
606 void app
607 .MCPOpenAppInstanceForTab(tabId, mcpApp.server, mcpApp.tool, mcpApp.generation, item.id, mcpApp.resourceUri)
608 .then((instance: MCPAppInstanceView | null) => {
609 if (instance) setAppInstance(instance);
610 })
611 .catch(() => undefined);
612 }}
613 >
614 {t("mcp.app.open")}
615 </button>
616 )}
617 </div>
618 )}
619
620 {errorText && (
621 <div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
622 {hasErrorDetails ? (
623 <>
624 <div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
625 <button
626 type="button"
627 className="tool__err-toggle"
628 onClick={() => { setShowErrorDetails((value) => !value); }}
629 aria-expanded={showErrorDetails}
630 >
631 <ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
632 <span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
633 </button>
634 {showErrorDetails && <div className="tool__err-details">{errorText}</div>}
635 </>
636 ) : (
637 errorText
638 )}
639 </div>
640 )}
641 </div>
642 </div>
643 );
644 });
645
645 lines Plain Text