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