// One transcript row, by kind. docs/STRUCTURED-VIEW.md fixes the eight kinds // and leaves every rendering decision here: markdown for AgentText, an inline // italic stream for AgentThought, a tool card for ToolCall/ToolDiff, a diff // view for ToolDiff's content, an approval card for Approval, and an opaque // collapsed line for a Notice nobody has taught the client about. `Plan` is // deliberately not drawn — the web client has no plan rail. import { useEffect, useRef, type ReactNode } from "react"; import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js"; import type { AcpContentBlock, AcpPermissionOption, AcpToolCallContent, AcpToolCallLike, StructuredApprovalDecision, TranscriptRow as Row, TurnMeta, } from "../../types.js"; import { ApprovalView } from "./ApprovalView.js"; import { Copyable } from "./Copyable.js"; import { DiffView } from "./DiffView.js"; import { contentBlockText, isStateOnlyNotice, splitToolTitle, statusMeta, toolDiffMeta, turnMeta, } from "./model.js"; import { safeMediaSource, toolPayload } from "./toolPayload.js"; import { ToolIcon } from "./ToolIcon.js"; // --------------------------------------------------------------------------- // content rows: user / agent / thought // --------------------------------------------------------------------------- /** Model-authored markdown, sanitized by render.ts before it enters * the DOM. Tables get their own scroller and links open out of the app. */ function Prose({ html }: { html: string }) { const ref = useRef(null); useEffect(() => { const container = ref.current; if (!container) return; for (const table of Array.from(container.querySelectorAll("table"))) { if (table.parentElement?.classList.contains("st-markdown-table-wrap")) continue; const scroller = document.createElement("div"); scroller.className = "st-markdown-table-wrap"; table.replaceWith(scroller); scroller.appendChild(table); } for (const link of container.querySelectorAll("a")) { link.target = "_blank"; link.rel = "noopener noreferrer"; } }, [html]); return
; } export interface ContentRowProps { row: Row; variant: "user" | "agent" | "thought"; /** Object URLs for images this prompt uploaded, keyed by the server path the * row's text names. */ imageUrls?: Map; } /** `Attached file "name": "path"` references the composer appends on send. */ const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g; function attachedImages(text: string, imageUrls: Map): string[] { const urls: string[] = []; ATTACHED_FILE_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = ATTACHED_FILE_RE.exec(text)) !== null) { let serverPath: string; try { serverPath = JSON.parse(`"${match[1]}"`) as string; } catch { continue; } const url = imageUrls.get(serverPath); if (url) urls.push(url); } return urls; } export function ContentRow({ row, variant, imageUrls }: ContentRowProps) { const data = row.data as { content?: AcpContentBlock } | undefined; const text = contentBlockText(data?.content); const displayText = variant === "thought" ? text.trim() : text; const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought"; let body: ReactNode; if (variant === "user") { const images = imageUrls && imageUrls.size > 0 ? attachedImages(displayText, imageUrls) : []; // With previews in hand the path references are noise — the image is the // attachment. Without them the text is all the operator has, so it stays. const shown = images.length > 0 ? displayText.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim() : displayText; body = (
{shown &&
{shown}
} {images.map((src) => ( Uploaded image ))}
); } else if (variant === "agent") { body = ; } else { body =
; } return ( {body} ); } // --------------------------------------------------------------------------- // tool rows // --------------------------------------------------------------------------- function ToolDetailBlock({ label, text }: { label: string; text: string }) { return (
{label}
{text}
); } function ContentBlockView({ block }: { block: AcpContentBlock }) { if (block.type === "text") { if (!block.text) return null; return ( {block.text} ); } if (block.type === "image") { const source = safeMediaSource(block.mimeType, block.data, "image"); if (source) return Tool output; return ; } if (block.type === "audio") { const source = safeMediaSource(block.mimeType, block.data, "audio"); if (source) return