| 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import type { ChatNode } from "../lib/chatViewSource"; |
| 3 | import type { ChatContentLoader } from "../lib/chatContentLoader"; |
| 4 | import { useT } from "../lib/i18n"; |
| 5 | import { diffsFor, subjectOf } from "../lib/tools"; |
| 6 | import { TerminalBlock } from "./harness-chat/TerminalBlock"; |
| 7 | import { DiffBlock } from "./harness-chat/DiffBlock"; |
| 8 | import { WebBlock } from "./harness-chat/WebBlock"; |
| 9 | import { normalizeSearchSources } from "../lib/searchSourcesPresentation"; |
| 10 | import { parseSearchSources, searchOutputMetadata } from "../lib/searchSources"; |
| 11 | import toolCss from "./harness-chat/ToolRow.styles"; |
| 12 | import { classifyTool, toolPresentation } from "../lib/chatToolPresentation"; |
| 13 | import { boundedPayloadSections, utf8Prefix } from "../lib/toolPayloadPreview"; |
| 14 | import "./harness-chat/TerminalBlock.css"; |
| 15 | import "./harness-chat/DiffBlock.css"; |
| 16 | import "./harness-chat/Pill.css"; |
| 17 | import "./harness-chat/ToolBody.css"; |
| 18 | import "./harness-chat/WebBlock.css"; |
| 19 | |
| 20 | export default function ChatToolBody({ item, loader }: { item: Extract<ChatNode, { kind: "tool" }>["item"]; loader: ChatContentLoader }) { |
| 21 | const t = useT(); |
| 22 | const [loaded, setLoaded] = useState<{ item: typeof item; text: string }>(); |
| 23 | const [pendingItem, setPendingItem] = useState<typeof item>(); |
| 24 | const [errorItem, setErrorItem] = useState<typeof item>(); |
| 25 | const busy = pendingItem === item; |
| 26 | const error = errorItem === item; |
| 27 | const epoch = useRef(0); |
| 28 | useEffect(() => { return () => { epoch.current++; }; }, [item]); |
| 29 | const full = loaded && loaded.item.id === item.id && loaded.item.args === item.args && |
| 30 | loaded.item.output === item.output && loaded.item.error === item.error && loaded.item.dataArchived === item.dataArchived |
| 31 | ? loaded.text : undefined; |
| 32 | const preview = JSON.stringify({ args: item.args, output: item.output, error: item.error, diff: item.fileDiff }); |
| 33 | const limited = full === undefined && (loader.needsFullContent(item, "tool") || preview.length > 8000); |
| 34 | const load = async () => { |
| 35 | const ticket = ++epoch.current; setPendingItem(item); setErrorItem(undefined); |
| 36 | try { |
| 37 | const text = await loader.load(item, "tool"); |
| 38 | if (ticket !== epoch.current) throw new Error("Tool changed or closed"); |
| 39 | const payload: unknown = JSON.parse(text); |
| 40 | if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid tool content"); |
| 41 | for (const key of ["args", "output", "error"] as const) { |
| 42 | if (key in payload && (payload as Record<string, unknown>)[key] != null && typeof (payload as Record<string, unknown>)[key] !== "string") throw new Error("Invalid tool content"); |
| 43 | } |
| 44 | setLoaded({ item, text }); |
| 45 | } catch { if (ticket === epoch.current) setErrorItem(item); } |
| 46 | finally { if (ticket === epoch.current) setPendingItem(undefined); } |
| 47 | }; |
| 48 | let value: { args?: string; output?: string; error?: string } = item; |
| 49 | if (full !== undefined) { try { value = JSON.parse(full); } catch { /* Keep the available preview. */ } } |
| 50 | let args: Record<string, unknown> = {}; |
| 51 | try { args = JSON.parse(value.args || "{}") || {}; } catch { /* Streaming arguments. */ } |
| 52 | const labels = { copy: t("msg.copy"), copied: t("msg.copied"), collapse: t("common.collapse"), collapseAria: t("common.collapse"), |
| 53 | expand: (hidden: number) => t("chat.expandLines", { count: hidden }), expandAria: (hidden: number) => t("chat.expandLines", { count: hidden }) }; |
| 54 | const diffs = !limited ? diffsFor(item.name, value.args || "{}") : []; |
| 55 | const kind = classifyTool(item); |
| 56 | const presentation = toolPresentation(item); |
| 57 | const search = kind === "search" && !limited ? normalizeSearchSources(item.searchSources ?? parseSearchSources(value.output || "")) : undefined; |
| 58 | const searchMeta = searchOutputMetadata(value.output); |
| 59 | return <> |
| 60 | {!limited && kind === "shell" && (item.execution || item.status === "running" || item.status === "stopped") ? <TerminalBlock command={typeof args.command === "string" ? args.command : value.args || ""} |
| 61 | output={value.error || value.output} running={presentation.state === "running"} exitCode={presentation.exitCode} |
| 62 | presentation={{ state: presentation.dot, label: t(presentation.label) }} |
| 63 | maxLines={200} className={toolCss.terminalBody} |
| 64 | labels={{ ...labels, signal: signal => signal, exitCode: code => `${code}`, running: t("chat.running"), failed: t("chat.failed"), done: t("chat.done"), noOutput: t("chat.noOutput") }} /> |
| 65 | : search && item.status === "done" ? <WebBlock kind="search" answer={searchMeta.summary ?? item.searchSummary} |
| 66 | sources={search.visible.map(source => ({ url: source.href, title: source.title }))} truncated={search.hiddenCount > 0} |
| 67 | labels={{ noResults: t("sources.notProvided"), sourcesTruncated: t("sources.hidden", { n: search.hiddenCount }), http: "HTTP", contentTruncated: t("chat.loadFull") }} className={toolCss.webBody} /> |
| 68 | : diffs.length ? <DiffBlock diffs={diffs.map(diff => ({ path: subjectOf(item.name, value.args || "{}"), oldText: diff.original, newText: diff.modified }))} |
| 69 | maxLines={200} labels={{ ...labels, files: count => t("chat.files", { count }) }} className={toolCss.diffBody} /> |
| 70 | : <div className={toolCss.ioCard}><ToolPayload text={full ?? preview} preview={limited} /></div>} |
| 71 | {limited && <button className="btn" disabled={busy} onClick={() => void load()}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>} |
| 72 | </>; |
| 73 | } |
| 74 | |
| 75 | export function ToolPayload({ text, preview }: { text: string; preview: boolean }) { |
| 76 | const t = useT(); |
| 77 | let value: unknown; |
| 78 | try { value = JSON.parse(text); } catch { return <pre>{preview ? utf8Prefix(text, 16 * 1024) : text}</pre>; } |
| 79 | if (!value || typeof value !== "object" || Array.isArray(value)) return <pre>{preview ? utf8Prefix(text, 16 * 1024) : text}</pre>; |
| 80 | const entries = preview ? boundedPayloadSections(value as Record<string, unknown>) |
| 81 | : Object.entries(value).filter(([, content]) => content != null).map(([key, content]) => ({ key, body: typeof content === "string" ? content : JSON.stringify(content, null, 2) })); |
| 82 | return <>{entries.map(({ key, body }) => { |
| 83 | const label = key === "args" ? t("chat.tool.input") : key === "output" ? t("chat.tool.output") : key; |
| 84 | return <section key={key} className={toolCss.ioSection}><span className={toolCss.ioLabel}>{label}</span><pre className={toolCss.ioText} data-error={key === "error" || undefined}>{body}</pre></section>; |
| 85 | })}</>; |
| 86 | } |
| 87 |