| 1 | import { lazy, Suspense, memo, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; |
| 2 | import { FileText, Globe, GitBranch, PackageOpen, Search, Terminal, Users, Wrench, X } from "lucide-react"; |
| 3 | import { ChatSource, type ChatNode } from "../lib/chatViewSource"; |
| 4 | import type { ChatContentLoader } from "../lib/chatContentLoader"; |
| 5 | import type { ChatScrollController } from "../lib/chatScrollController"; |
| 6 | import { reconcileMountedOrder, revealEarlierMountedOrder, type ChatMountedOrder } from "../lib/chatMountedOrder"; |
| 7 | import { forkBlockReason, forkReasonKey, type ForkBlockReason, type ForkTargetView } from "../lib/forkTargets"; |
| 8 | import { useT } from "../lib/i18n"; |
| 9 | import { AssistantMessage, UserMessage } from "./Message"; |
| 10 | import { CopyButton } from "./CopyButton"; |
| 11 | import { Markdown } from "./Markdown"; |
| 12 | import { ExtensionCard } from "./ExtensionCard"; |
| 13 | import { Tooltip } from "./Tooltip"; |
| 14 | import { formatMessageClock, TurnTimePanel, TurnUsagePanel } from "./TurnStats"; |
| 15 | import { ReasoningRow } from "./harness-chat/ReasoningRow"; |
| 16 | import { TurnProcessNodeView } from "./harness-chat/TurnProcessNodeView"; |
| 17 | import { ContextInjectionRow } from "./harness-chat/ContextInjectionRow"; |
| 18 | import { ToolRow } from "./harness-chat/ToolRow"; |
| 19 | import { subjectOf, summarizeFileDiff } from "../lib/tools"; |
| 20 | import { classifyTool, shellDisplayName, toolPresentation } from "../lib/chatToolPresentation"; |
| 21 | import { RESOURCE_BUDGETS } from "../lib/resourceBudgets"; |
| 22 | const ChatToolBody = lazy(() => import("./ChatToolBody")); |
| 23 | const ToolPayload = lazy(() => import("./ChatToolBody").then(module => ({ default: module.ToolPayload }))); |
| 24 | const PresentedFiles = lazy(() => import("./PresentedFiles").then(module => ({ default: module.PresentedFiles }))); |
| 25 | const ModifiedFiles = lazy(() => import("./PresentedFiles").then(module => ({ default: module.ModifiedFiles }))); |
| 26 | const TOOL_RELATION_PAGE_SIZE = RESOURCE_BUDGETS.toolRelationsPerPage; |
| 27 | |
| 28 | export function useChatNode(source: ChatSource, key: string) { |
| 29 | const subscribe = useCallback((listener: () => void) => source.subscribeNode(key, listener), [source, key]); |
| 30 | const snapshot = useCallback(() => source.getNodeSnapshot(key), [source, key]); |
| 31 | return useSyncExternalStore(subscribe, snapshot, snapshot); |
| 32 | } |
| 33 | /** |
| 34 | * The transcript's fork affordance. Every question about one turn is answered |
| 35 | * from the host's persisted turn records, so the entry never depends on |
| 36 | * checkpoints, on whether the session is running, or on a page offset. |
| 37 | */ |
| 38 | export type ChatForkAction = { |
| 39 | /** Persisted boundary of a tail's answer message, undefined when the source keeps none. */ |
| 40 | targetFor: (answerKey: string | undefined) => ForkTargetView | undefined; |
| 41 | /** False until this session's target set arrives; every entry reads as loading. */ |
| 42 | loaded: boolean; |
| 43 | /** True when the source keeps persisted turn records at all. */ |
| 44 | verifiable: boolean; |
| 45 | /** Non-null replaces every entry's own state, e.g. a create request already in flight. */ |
| 46 | blocked: ForkBlockReason | null; |
| 47 | create: (target: ForkTargetView) => void; |
| 48 | }; |
| 49 | export type ChatActions = { |
| 50 | openDetails: (key: string, trigger: HTMLElement) => void; |
| 51 | /** Absent on surfaces that cannot fork at all; those render no branch entry. */ |
| 52 | fork?: ChatForkAction; |
| 53 | recover: (id: string) => void; |
| 54 | }; |
| 55 | type SeatProps = { source: ChatSource; nodeKey: string; loader: ChatContentLoader; scroll: ChatScrollController; actions: ChatActions; tabId?: string; hostId?: string }; |
| 56 | |
| 57 | export const ChatNodeList = memo(function ChatNodeList(props: Omit<SeatProps, "nodeKey"> & { mounts: ChatMountedOrder }) { |
| 58 | const order = useSyncExternalStore(props.source.subscribeOrder, props.source.getOrderSnapshot, props.source.getOrderSnapshot); |
| 59 | const [visible, setVisible] = useState<readonly string[]>(order); |
| 60 | const committedVisibleRef = useRef(visible); |
| 61 | committedVisibleRef.current = visible; |
| 62 | const visibleRef = useRef(visible); |
| 63 | // Harness keeps every business node under one keyed parent. Tail growth and |
| 64 | // replacements are synchronous; a leading history page alone is revealed in |
| 65 | // bounded frames without ever regrouping the nodes already in the document. |
| 66 | const rendered = reconcileMountedOrder(visible, order); |
| 67 | visibleRef.current = rendered; |
| 68 | useLayoutEffect(() => props.mounts.publish(rendered), [props.mounts, rendered]); |
| 69 | useEffect(() => { |
| 70 | let frame = requestAnimationFrame(function revealPrepend() { |
| 71 | const current = visibleRef.current; |
| 72 | if (!current.length) { visibleRef.current = order; committedVisibleRef.current = order; setVisible(order); return; } |
| 73 | const next = revealEarlierMountedOrder(current, order); |
| 74 | if (next === current) { |
| 75 | // Tail growth is visible immediately. Commit the reconciled order so a |
| 76 | // later history prepend starts from the actual mounted suffix. |
| 77 | if (committedVisibleRef.current !== current) { |
| 78 | committedVisibleRef.current = current; |
| 79 | setVisible(current); |
| 80 | } |
| 81 | return; |
| 82 | } |
| 83 | visibleRef.current = next; |
| 84 | committedVisibleRef.current = next; |
| 85 | setVisible(next); |
| 86 | if (next.length < order.length) frame = requestAnimationFrame(revealPrepend); |
| 87 | }); |
| 88 | return () => cancelAnimationFrame(frame); |
| 89 | }, [order]); |
| 90 | return rendered.map(key => <ChatNodeSeat key={key} source={props.source} loader={props.loader} scroll={props.scroll} |
| 91 | actions={props.actions} tabId={props.tabId} hostId={props.hostId} nodeKey={key} />); |
| 92 | }); |
| 93 | |
| 94 | const ChatNodeSeat = memo(function ChatNodeSeat({ source, nodeKey, loader, scroll, actions, tabId, hostId }: SeatProps) { |
| 95 | const node = useChatNode(source, nodeKey); |
| 96 | const process = useChatNode(source, `${node?.turnKey}:process`); |
| 97 | const t = useT(); |
| 98 | if (!node) return null; |
| 99 | const hidden = process?.kind === "process" && process.collapsed && process.members.includes(nodeKey); |
| 100 | if (hidden) return null; |
| 101 | let body; |
| 102 | switch (node.kind) { |
| 103 | case "user": body = <ChatUser node={node} loader={loader} />; break; |
| 104 | case "assistant": body = node.item.text || node.item.searchSources?.length || node.item.memoryCitations?.length || loader.needsFullContent(node.item, "content") ? <ChatAnswer node={node} loader={loader} source={source} tabId={tabId} hostId={hostId} /> : null; break; |
| 105 | case "reasoning": body = node.item.reasoning || loader.needsFullContent(node.item, "reasoning") ? <ChatReasoning node={node} loader={loader} source={source} scroll={scroll} /> : null; break; |
| 106 | case "process": body = <TurnProcessNodeView node={node} onToggle={() => { scroll.beforeChange(); source.toggleProcess(node.turnKey); }} />; break; |
| 107 | case "tool": body = <ChatTool node={node} loader={loader} actions={actions} scroll={scroll} />; break; |
| 108 | case "phase": body = <ContextInjectionRow title={t("chat.activity")} summary={node.item.text} beforeToggle={scroll.beforeChange}>{node.item.text}</ContextInjectionRow>; break; |
| 109 | case "notice": body = <ChatNotice node={node} actions={actions} scroll={scroll} />; break; |
| 110 | case "compaction": body = <ChatDisclosure label={t("chat.compaction")}><Markdown text={node.item.summary} /></ChatDisclosure>; break; |
| 111 | case "extension": body = node.item.card.actions?.length ? <ExtensionCard item={node.item} tabId={tabId} /> : |
| 112 | <ChatDisclosure label={node.item.card.title || node.item.pluginId}><ExtensionCard item={node.item} tabId={tabId} /></ChatDisclosure>; break; |
| 113 | case "tail": body = <ChatTurnTail node={node} source={source} actions={actions} loader={loader} tabId={tabId} hostId={hostId} />; break; |
| 114 | } |
| 115 | return <div className="chat-node" data-chat-anchor-key={node.key} data-chat-turn={node.turnKey} data-chat-kind={node.kind} |
| 116 | data-turn-process-answer={node.kind === "assistant" && process?.kind === "process" && process.collapsed && !process.members.includes(node.key) || undefined}>{body}</div>; |
| 117 | }); |
| 118 | |
| 119 | function ChatNotice({ node, actions, scroll }: { node: Extract<ChatNode, { kind: "notice" }>; actions: ChatActions; scroll: ChatScrollController }) { |
| 120 | const t = useT(); |
| 121 | const item = node.item; |
| 122 | const summary = item.completionSummary; |
| 123 | if (item.code === "capability_proxy_audit") return <ChatDisclosure label={t("chat.details")}><pre>{item.text}{"\n"}{item.detail}</pre></ChatDisclosure>; |
| 124 | // Empty delivery accounting is not a chat result. Keep meaningful records in details. |
| 125 | if (summary && !summary.mutations && !summary.changed_files && !summary.checks_passed && !summary.checks_failed) return null; |
| 126 | if (item.level === "warn" || item.action === "recover_context") return <div className="chat-notice" role="status" data-level={item.level}> |
| 127 | {item.title && <strong>{item.title} </strong>}{item.text} |
| 128 | {summary && <details className="chat-notice__details"><summary>{t("chat.details")}</summary><pre>{JSON.stringify(summary, null, 2)}</pre></details>} |
| 129 | {item.detail && <ChatDisclosure label={t("chat.details")}><pre>{item.detail}</pre></ChatDisclosure>} |
| 130 | {item.action === "recover_context" && item.recoveryId && <button className="btn" onClick={() => actions.recover(item.recoveryId!)}>{t("notice.protocolRecoveryAction")}</button>} |
| 131 | </div>; |
| 132 | return <ContextInjectionRow title={item.title || t(item.decisionReceipt ? "chat.decision" : summary ? "chat.record" : "chat.notice")} |
| 133 | summary={item.decisionReceipt ? undefined : item.text.split("\n")[0]} beforeToggle={scroll.beforeChange}> |
| 134 | <pre>{item.text}{item.detail ? `\n${item.detail}` : ""}{summary ? `\n${JSON.stringify(summary, null, 2)}` : ""}</pre> |
| 135 | </ContextInjectionRow>; |
| 136 | } |
| 137 | |
| 138 | function ChatTool({ node, loader, actions, scroll }: { node: Extract<ChatNode, { kind: "tool" }>; loader: ChatContentLoader; actions: ChatActions; scroll: ChatScrollController }) { |
| 139 | const t = useT(); |
| 140 | const item = node.item; |
| 141 | let args: Record<string, unknown> = {}; |
| 142 | try { args = JSON.parse(item.args) || {}; } catch { /* Arguments may still be streaming. */ } |
| 143 | const description = typeof args.description === "string" ? args.description : ""; |
| 144 | const presentCount = Array.isArray(args.files) ? args.files.length : item.presentedFiles?.length ?? 0; |
| 145 | const presentSummary = item.name === "present" |
| 146 | ? t(item.status === "running" ? "present.presenting" : item.status === "done" ? "present.presented" : "present.failed", { count: presentCount }) |
| 147 | : ""; |
| 148 | const summary = presentSummary || description || subjectOf(item.name, item.args) || item.subject || item.summary || ""; |
| 149 | const toolKind = classifyTool(item); |
| 150 | const presentation = toolPresentation(item); |
| 151 | const Icon = toolKind === "present" ? PackageOpen : { search: Search, web: Globe, shell: Terminal, agent: Users, file: FileText, tool: Wrench }[toolKind]; |
| 152 | const title = item.name === "web_search" ? t("chat.tool.search") : item.name === "web_fetch" ? t("chat.tool.web") |
| 153 | : item.name === "present" ? t("present.toolTitle") : toolKind === "shell" ? shellDisplayName(item) : item.resolvedName || item.name; |
| 154 | return <ToolRow icon={<Icon size={14} />} title={title} summary={[summary, summarizeFileDiff(item.fileDiff)].filter(Boolean).join(" · ")} |
| 155 | state={presentation.state} dot={presentation.dot} statusLabel={t(presentation.label)} |
| 156 | errorSummary={item.error?.trim().split("\n")[0]} beforeToggle={scroll.beforeChange} |
| 157 | inspectLabel={t("chat.details")} inspect={trigger => actions.openDetails(node.key, trigger)}> |
| 158 | <Suspense fallback={<p role="status">{t("chat.loading")}</p>}><ChatToolBody item={item} loader={loader} /></Suspense> |
| 159 | </ToolRow>; |
| 160 | } |
| 161 | |
| 162 | |
| 163 | function ChatDisclosure({ label, children }: { label: string; children: import("react").ReactNode }) { |
| 164 | const [open, setOpen] = useState(false); |
| 165 | return <details className="chat-notice" open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>{label}</summary>{open && children}</details>; |
| 166 | } |
| 167 | |
| 168 | function BodyLoadError({ item, loader }: { item: Extract<ChatNode, { kind: "assistant" | "user" }>["item"]; loader: ChatContentLoader }) { |
| 169 | const [error, setError] = useState(false); |
| 170 | const [attempt, setAttempt] = useState(0); |
| 171 | const t = useT(); |
| 172 | useEffect(() => { |
| 173 | let cancelled = false; |
| 174 | if (item.kind !== "assistant" || !item.streaming) void loader.load(item, "content").then( |
| 175 | () => { if (!cancelled) setError(false); }, () => { if (!cancelled) setError(true); }); |
| 176 | return () => { cancelled = true; }; |
| 177 | }, [loader, item, attempt]); |
| 178 | return error && <button className="btn" onClick={() => { setError(false); setAttempt(value => value + 1); }}>{t("chat.loadFailed")}</button>; |
| 179 | } |
| 180 | function ChatUser({ node, loader }: { node: Extract<ChatNode, { kind: "user" }>; loader: ChatContentLoader }) { |
| 181 | const t = useT(); |
| 182 | return <><UserMessage id={node.item.id} text={node.item.text} submitText={node.item.submitText} failed={node.item.failed} createdAt={node.item.createdAt} /> |
| 183 | {node.item.submissionState === "unknown" && !node.item.messageId && <span role="status">{t("chat.submissionUnknown")}</span>} |
| 184 | <BodyLoadError item={node.item} loader={loader} /></>; |
| 185 | } |
| 186 | function ChatAnswer({ node, loader, source, tabId, hostId }: { node: Extract<ChatNode, { kind: "assistant" }>; loader: ChatContentLoader; source: ChatSource; tabId?: string; hostId?: string }) { |
| 187 | const tail = useChatNode(source, `${node.turnKey}:tail`); |
| 188 | const presentedFiles = tail?.kind === "tail" ? tail.presentedFiles : []; |
| 189 | const modifiedFiles = tail?.kind === "tail" ? tail.modifiedFiles : []; |
| 190 | // A turn's file facts are the answers the host can already give without |
| 191 | // reading the answer text; when they grow, earlier reference failures are |
| 192 | // worth asking about again. |
| 193 | const factsVersion = presentedFiles.length + modifiedFiles.length; |
| 194 | return <><AssistantMessage item={node.item} presentedFiles={presentedFiles} modifiedFiles={modifiedFiles} |
| 195 | turnKey={node.turnKey} factsVersion={factsVersion} tabId={tabId} hostId={hostId} /><BodyLoadError item={node.item} loader={loader} /></>; |
| 196 | } |
| 197 | |
| 198 | function ChatReasoning({ node, loader, source, scroll }: { node: Extract<ChatNode, { kind: "reasoning" }>; loader: ChatContentLoader; source: ChatSource; scroll: ChatScrollController }) { |
| 199 | const t = useT(); |
| 200 | const [result, setResult] = useState<{ item: unknown; text: string }>(); |
| 201 | const [busy, setBusy] = useState(false); |
| 202 | const [error, setError] = useState(false); |
| 203 | const epoch = useRef(0); |
| 204 | useEffect(() => { return () => { epoch.current++; }; }, []); |
| 205 | const text = node.item.reasoning; |
| 206 | const full = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined; |
| 207 | if (!text) return null; |
| 208 | const needsFull = full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning")); |
| 209 | const load = async () => { |
| 210 | const ticket = ++epoch.current; setBusy(true); setError(false); |
| 211 | try { |
| 212 | const value = await loader.load(node.item, "reasoning"); |
| 213 | const current = source.getNodeSnapshot(node.key); |
| 214 | if (ticket !== epoch.current || current?.kind !== "reasoning" || (current.item !== node.item && current.item.reasoning !== value)) throw new Error("Reasoning content changed; retry"); |
| 215 | setResult({ item: current.item, text: value }); return value; |
| 216 | } |
| 217 | catch (error) { if (ticket === epoch.current) setError(true); throw error; } |
| 218 | finally { if (ticket === epoch.current) setBusy(false); } |
| 219 | }; |
| 220 | return <div className="chat-reasoning"><ReasoningRow text={text} running={node.item.streaming} t={t} beforeToggle={scroll.beforeChange} |
| 221 | duration={node.item.reasoningDurationMs != null ? `${(node.item.reasoningDurationMs / 1000).toFixed(1)}s` : undefined}> |
| 222 | <div className="chat-reasoning__body">{full ?? text.slice(0, 8000)} |
| 223 | {needsFull && <button className="btn" disabled={busy} onClick={() => void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>} |
| 224 | <CopyButton getText={() => full === undefined ? load() : full} label={t("chat.copyFull")} /> |
| 225 | </div></ReasoningRow></div>; |
| 226 | } |
| 227 | |
| 228 | function ChatTurnTail({ node, source, actions, loader, tabId, hostId }: { node: Extract<ChatNode, { kind: "tail" }>; source: ChatSource; actions: ChatActions; loader: ChatContentLoader; tabId?: string; hostId?: string }) { |
| 229 | const answer = useChatNode(source, node.answerKey ?? ""); |
| 230 | const t = useT(); |
| 231 | const hasAnswer = answer?.kind === "assistant" && Boolean(answer.item.text.trim()); |
| 232 | if (!hasAnswer && !node.presentedFiles.length && !node.modifiedFiles.length) return null; |
| 233 | const fork = actions.fork; |
| 234 | // A tail with no answer has no message identity, so it can name no boundary. |
| 235 | const target = hasAnswer ? fork?.targetFor(node.answerKey) : undefined; |
| 236 | const reason = fork ? forkBlockReason({ target, loaded: fork.loaded, verifiable: fork.verifiable, blocked: fork.blocked, latest: node.latest }) : null; |
| 237 | const reasonText = reason ? t(forkReasonKey(reason)) : ""; |
| 238 | const create = fork?.create; |
| 239 | return <div className="chat-turn-tail"> |
| 240 | {node.presentedFiles.length > 0 && <Suspense fallback={null}> |
| 241 | <PresentedFiles files={node.presentedFiles} tabId={tabId} hostId={hostId} /> |
| 242 | </Suspense>} |
| 243 | {node.modifiedFiles.length > 0 && <Suspense fallback={null}> |
| 244 | <ModifiedFiles files={node.modifiedFiles} tabId={tabId} hostId={hostId} /> |
| 245 | </Suspense>} |
| 246 | {hasAnswer && <div className="chat-actions" data-actions-reveal={node.latest ? "always" : "hover"}><CopyButton getText={async () => { |
| 247 | const text = answer.item.streaming ? answer.item.text : await loader.load(answer.item, "content"); |
| 248 | const current = source.getNodeSnapshot(answer.key); |
| 249 | if (current?.kind !== "assistant" || (current.item !== answer.item && current.item.text !== text)) throw new Error("Answer changed; retry"); |
| 250 | return text; |
| 251 | }} label={t("msg.copy")} showInlineLabel={false} className="chat-action-icon" /> |
| 252 | {fork && <Tooltip label={reason ? reasonText : t("chat.branch")} side="bottom"> |
| 253 | <button |
| 254 | type="button" |
| 255 | className="chat-action-icon" |
| 256 | aria-label={reason ? `${t("chat.branch")}: ${reasonText}` : t("chat.branch")} |
| 257 | aria-disabled={reason ? true : undefined} |
| 258 | data-unavailable={reason ? true : undefined} |
| 259 | onClick={reason || !target || !create ? undefined : () => create(target)} |
| 260 | ><GitBranch aria-hidden="true" /></button> |
| 261 | </Tooltip>} |
| 262 | {answer!.item.turnUsage && answer!.item.turnUsage.totalTokens > 0 && <TurnUsagePanel usage={answer!.item.turnUsage} />} |
| 263 | {(answer!.item.turnDurationMs ?? answer!.item.workDurationMs) != null && <TurnTimePanel |
| 264 | durationMs={(answer!.item.turnDurationMs ?? answer!.item.workDurationMs)!} |
| 265 | tokensPerSecond={answer!.item.tokensPerSecond} |
| 266 | />} |
| 267 | {answer!.item.createdAt != null && <time className="chat-actions__time" dateTime={new Date(answer!.item.createdAt).toISOString()}>{formatMessageClock(answer!.item.createdAt)}</time>} |
| 268 | {answer!.item.samplingCount !== undefined && <span>{t("chat.turnCounts", { samples: answer!.item.samplingCount, tools: answer!.item.toolCount ?? 0 })}</span>} |
| 269 | </div>} |
| 270 | </div>; |
| 271 | } |
| 272 | |
| 273 | export function ChatDetails({ source, nodeKey, loader, onClose, onNavigate }: { source: ChatSource; nodeKey: string; loader: ChatContentLoader; onClose: () => void; onNavigate: (key: string) => void }) { |
| 274 | const node = useChatNode(source, nodeKey); |
| 275 | const subscribeChildren = useCallback((listener: () => void) => source.subscribeNode(`${nodeKey}:children`, listener), [source, nodeKey]); |
| 276 | const getChildren = useCallback(() => source.toolChildren(nodeKey), [source, nodeKey]); |
| 277 | const children = useSyncExternalStore(subscribeChildren, getChildren, getChildren); |
| 278 | const t = useT(); |
| 279 | const [result, setResult] = useState<{ item: unknown; text: string }>(); |
| 280 | const [busy, setBusy] = useState(false); |
| 281 | const [error, setError] = useState(false); |
| 282 | const [selectedTab, setSelectedTab] = useState<"result" | "parameters" | "children" | "raw">("result"); |
| 283 | const [visibleChildren, setVisibleChildren] = useState<number>(TOOL_RELATION_PAGE_SIZE); |
| 284 | const root = useRef<HTMLElement>(null); |
| 285 | const epoch = useRef(0); |
| 286 | useEffect(() => { root.current?.focus(); return () => { epoch.current++; }; }, []); |
| 287 | useEffect(() => setVisibleChildren(TOOL_RELATION_PAGE_SIZE), [nodeKey]); |
| 288 | const load = async () => { |
| 289 | if (node?.kind !== "tool") throw new Error("Tool unavailable"); |
| 290 | const ticket = ++epoch.current; setBusy(true); setError(false); |
| 291 | try { const text = await loader.load(node.item, "tool"); if (ticket === epoch.current) { |
| 292 | const current = source.getNodeSnapshot(nodeKey); |
| 293 | if (current?.kind !== "tool" || (current.item !== node.item && !matchesToolContent(current.item, text))) throw new Error("Tool content changed; retry"); |
| 294 | setResult({ item: current.item, text }); |
| 295 | return text; |
| 296 | } throw new Error("Detail closed"); } |
| 297 | catch (error) { if (ticket === epoch.current) setError(true); throw error; } |
| 298 | finally { if (ticket === epoch.current) setBusy(false); } |
| 299 | }; |
| 300 | if (node?.kind !== "tool") return null; |
| 301 | const full = result && (result.item === node.item || matchesToolContent(node.item, result.text)) ? result.text : undefined; |
| 302 | const preview = JSON.stringify({ args: node.item.args, output: node.item.output, error: node.item.error, diff: node.item.fileDiff }, null, 2); |
| 303 | const hasResult = Boolean(node.item.output || node.item.error || node.item.fileDiff); |
| 304 | const hasParameters = Boolean(node.item.args.trim() && node.item.args.trim() !== "{}"); |
| 305 | const hasRelations = Boolean(node.item.parentId || children.length); |
| 306 | const tabs = [hasResult && "result", hasParameters && "parameters", hasRelations && "children", "raw"].filter(Boolean) as Array<"result" | "parameters" | "children" | "raw">; |
| 307 | const activeTab = tabs.includes(selectedTab) ? selectedTab : tabs[0]; |
| 308 | const title = classifyTool(node.item) === "shell" ? shellDisplayName(node.item) : node.item.resolvedName ?? node.item.name; |
| 309 | let formattedArgs = node.item.args; |
| 310 | try { formattedArgs = JSON.stringify(JSON.parse(node.item.args), null, 2); } catch { /* Streaming or unknown arguments stay copyable. */ } |
| 311 | const resultPreview = JSON.stringify({ output: node.item.output, error: node.item.error, diff: node.item.fileDiff }, null, 2); |
| 312 | let resultText = resultPreview; |
| 313 | if (full !== undefined) { |
| 314 | try { |
| 315 | const payload = JSON.parse(full) as Record<string, unknown>; |
| 316 | resultText = JSON.stringify({ output: payload.output, error: payload.error, diff: payload.diff }, null, 2); |
| 317 | } catch { resultText = full; } |
| 318 | } |
| 319 | return <aside ref={root} className="chat-details" role="dialog" aria-modal="true" aria-label={t("chat.details")} tabIndex={-1} onKeyDown={event => { |
| 320 | if (event.key === "Escape") { event.stopPropagation(); onClose(); } |
| 321 | if (event.key === "Tab") { |
| 322 | const buttons = Array.from(root.current?.querySelectorAll<HTMLElement>("button:not(:disabled), [href], [tabindex='0']") ?? []); |
| 323 | const first = buttons[0], last = buttons[buttons.length - 1]; |
| 324 | if (event.shiftKey && (document.activeElement === first || document.activeElement === root.current)) { event.preventDefault(); last?.focus(); } |
| 325 | else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } |
| 326 | } |
| 327 | }}> |
| 328 | <header><strong>{title}</strong><button className="btn" aria-label={t("common.close")} onClick={onClose}><X size={16} /></button></header> |
| 329 | <div className="chat-details__tabs" role="tablist" aria-label={t("chat.details")}> |
| 330 | {tabs.map(tab => <button key={tab} type="button" role="tab" aria-selected={activeTab === tab} onClick={() => setSelectedTab(tab)}>{t(`chat.details.${tab}`)}</button>)} |
| 331 | </div> |
| 332 | <div className="chat-details__body"> |
| 333 | {(activeTab === "result" || activeTab === "raw") && full === undefined && <button className="btn" disabled={busy} onClick={() => void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>} |
| 334 | {(activeTab === "result" || activeTab === "raw") && <CopyButton getText={() => full === undefined ? load() : full} label={t("chat.copyFull")} />} |
| 335 | {activeTab === "result" && <Suspense fallback={<p role="status">{t("chat.loading")}</p>}><ToolPayload text={resultText} preview={full === undefined} /></Suspense>} |
| 336 | {activeTab === "parameters" && <pre>{formattedArgs}</pre>} |
| 337 | {activeTab === "children" && <div className="chat-details__relations"> |
| 338 | {node.item.parentId && source.getNodeSnapshot(node.item.parentId)?.kind === "tool" && <button className="btn" onClick={() => onNavigate(node.item.parentId!)}>← {t("chat.details.parent")}</button>} |
| 339 | {children.slice(0, visibleChildren).map(child => <button className="chat-tool" key={child.key} onClick={() => onNavigate(child.key)}>{child.item.resolvedName ?? child.item.name} · {child.item.status}</button>)} |
| 340 | {children.length > visibleChildren && <button className="btn" data-testid="tool-children-more" onClick={() => setVisibleChildren(count => count + TOOL_RELATION_PAGE_SIZE)}>{t("chat.loadMoreTools", { count: Math.min(TOOL_RELATION_PAGE_SIZE, children.length - visibleChildren) })}</button>} |
| 341 | </div>} |
| 342 | {activeTab === "raw" && <><pre>{full ?? preview}</pre>{source.toolAudits(node.item.id).map((audit, index) => <pre key={index}>{audit}</pre>)}</>} |
| 343 | </div> |
| 344 | </aside>; |
| 345 | } |
| 346 | |
| 347 | function matchesToolContent(item: Extract<ChatNode, { kind: "tool" }>["item"], text: string): boolean { |
| 348 | try { const value = JSON.parse(text); return value.args === item.args && value.output === item.output && value.error === item.error && JSON.stringify(value.diff) === JSON.stringify(item.fileDiff); } |
| 349 | catch { return false; } |
| 350 | } |
| 351 | |
| 352 | export function ChatRunning({ source }: { source: ChatSource }) { |
| 353 | const status = useSyncExternalStore(source.subscribeStatus, source.getStatusSnapshot, source.getStatusSnapshot); |
| 354 | const t = useT(); |
| 355 | const [now, setNow] = useState(Date.now); |
| 356 | useEffect(() => { if (!status.running) return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [status.running]); |
| 357 | return status.running ? <div className="chat-running" role="status">{t("chat.running")} {status.startedAt ? `${Math.max(0, Math.floor((now - status.startedAt) / 1000))}s` : ""}</div> : null; |
| 358 | } |
| 359 |