| 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 |