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