Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the 23 views it drove are gone; App.tsx composes the ported components — sidebar, multi-view, command bar, spawn modal, help and restart overlays, daemon link, drawer — around the catalog and layout stores. The pure parts of four deleted modules moved next to the components that still use them: the spawn-form model and spawnSimilar into components/spawn, the transcript's markdown/format helpers and the diff into components/structured, the help overlay's grouping into components/dialogs. Document-level event names now live in uiEvents.ts. Deduplicated two pairs the migration left side by side: the sidebar uses dialogs/KillDialog and common/ContextMenu. It also listens for the command bar's kill/rename events, which nothing had picked up since catalog.ts went away. styles.css stays as the token and base layer, with `#app` renamed to `#root` and the pane-body rules re-keyed off the classes the React panes actually carry. Closes #253
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
// 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<HTMLDivElement | null>(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 <div ref={ref} className="st-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
|
}
|
|
|
|
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<string, string>;
|
|
}
|
|
|
|
/** `Attached file "name": "path"` references the composer appends on send. */
|
|
const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
|
|
|
function attachedImages(text: string, imageUrls: Map<string, string>): 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 = (
|
|
<div className="st-body">
|
|
{shown && <div className="st-user-text">{shown}</div>}
|
|
{images.map((src) => (
|
|
<img key={src} className="st-user-image" src={src} alt="Uploaded image" loading="lazy" />
|
|
))}
|
|
</div>
|
|
);
|
|
} else if (variant === "agent") {
|
|
body = <Prose html={renderMarkdown(displayText)} />;
|
|
} else {
|
|
body = <div className="st-body" dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(displayText) }} />;
|
|
}
|
|
|
|
return (
|
|
<Copyable className={`st-row st-row-${variant}`} text={displayText} label={label} data-row-id={row.id}>
|
|
{body}
|
|
</Copyable>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tool rows
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ToolDetailBlock({ label, text }: { label: string; text: string }) {
|
|
return (
|
|
<div className="st-tool-detail">
|
|
<div className="st-tool-detail-label">{label}</div>
|
|
<Copyable as="pre" text={text} label={label.toLowerCase()}>
|
|
{text}
|
|
</Copyable>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContentBlockView({ block }: { block: AcpContentBlock }) {
|
|
if (block.type === "text") {
|
|
if (!block.text) return null;
|
|
return (
|
|
<Copyable className="st-tool-text" text={block.text} label="output">
|
|
{block.text}
|
|
</Copyable>
|
|
);
|
|
}
|
|
if (block.type === "image") {
|
|
const source = safeMediaSource(block.mimeType, block.data, "image");
|
|
if (source) return <img className="st-tool-image" src={source} alt="Tool output" loading="lazy" />;
|
|
return <ToolDetailBlock label="Image" text={block.uri ?? block.mimeType ?? "Unavailable"} />;
|
|
}
|
|
if (block.type === "audio") {
|
|
const source = safeMediaSource(block.mimeType, block.data, "audio");
|
|
if (source) return <audio className="st-tool-audio" controls src={source} />;
|
|
return <ToolDetailBlock label="Audio" text={block.mimeType ?? "Unavailable"} />;
|
|
}
|
|
if (block.type === "resource_link") {
|
|
return (
|
|
<div className="st-tool-resource">
|
|
<span className="st-tool-resource-name">{block.title ?? block.name ?? "Resource"}</span>
|
|
{block.uri && (
|
|
<Copyable as="code" className="st-tool-resource-uri" text={block.uri} label="resource URI">
|
|
{block.uri}
|
|
</Copyable>
|
|
)}
|
|
{block.description && <span className="st-tool-resource-description">{block.description}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
const resource = block.resource;
|
|
if (typeof resource?.text === "string") {
|
|
return (
|
|
<ToolDetailBlock label={resource.uri ? `Resource · ${resource.uri}` : "Resource"} text={resource.text} />
|
|
);
|
|
}
|
|
const printed = resource === undefined ? "" : JSON.stringify(resource, null, 2);
|
|
return printed ? <ToolDetailBlock label="Resource" text={printed} /> : null;
|
|
}
|
|
|
|
function ToolBody({ tool, content }: { tool: AcpToolCallLike; content: AcpToolCallContent[] }) {
|
|
const payload = toolPayload(tool, content);
|
|
const hasRaw = payload.raw.input !== null || payload.raw.output !== null;
|
|
return (
|
|
<>
|
|
{content.map((block, index) => {
|
|
if (block.type === "content") return <ContentBlockView key={index} block={block.content} />;
|
|
if (block.type === "diff") return <DiffView key={index} diff={block} />;
|
|
return (
|
|
<div key={index} className="st-tool-terminal">
|
|
{block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"}
|
|
</div>
|
|
);
|
|
})}
|
|
{payload.details.map((detail, index) => (
|
|
<ToolDetailBlock key={`${detail.label}-${index}`} label={detail.label} text={detail.text} />
|
|
))}
|
|
{payload.exitCode !== null && (
|
|
<div className="st-tool-fact">
|
|
<span className="st-tool-fact-label">Exit code</span>
|
|
<code className={payload.exitCode === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"}>
|
|
{payload.exitCode}
|
|
</code>
|
|
</div>
|
|
)}
|
|
{hasRaw && (
|
|
<details className="st-tool-raw">
|
|
<summary>Raw JSON</summary>
|
|
<div className="st-tool-raw-body">
|
|
{payload.raw.input !== null && <ToolDetailBlock label="Input" text={payload.raw.input} />}
|
|
{payload.raw.output !== null && <ToolDetailBlock label="Output" text={payload.raw.output} />}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** True when a tool call has anything at all to disclose — a head with nothing
|
|
* behind it stays a plain line rather than an empty disclosure. */
|
|
function hasToolBody(tool: AcpToolCallLike, content: AcpToolCallContent[]): boolean {
|
|
if (content.length > 0) return true;
|
|
const payload = toolPayload(tool, content);
|
|
return payload.details.length > 0 || payload.exitCode !== null || payload.raw.input !== null || payload.raw.output !== null;
|
|
}
|
|
|
|
export interface ToolRowProps {
|
|
row: Row;
|
|
expanded: boolean;
|
|
onExpandedChange(expanded: boolean): void;
|
|
}
|
|
|
|
export function ToolRow({ row, expanded, onExpandedChange }: ToolRowProps) {
|
|
const tool = (row.data ?? {}) as AcpToolCallLike;
|
|
const content = tool.content ?? [];
|
|
const hasDiff = content.some((c) => c.type === "diff");
|
|
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
|
|
const meta = statusMeta(tool.status, hasDiff);
|
|
const metaParts: string[] = [];
|
|
if (hasDiff) metaParts.push(toolDiffMeta(content));
|
|
if (meta) metaParts.push(meta.text);
|
|
const disclosable = hasToolBody(tool, content);
|
|
const open = expanded && disclosable;
|
|
|
|
return (
|
|
<div className="st-row st-row-tool" data-row-id={row.id}>
|
|
<div className="st-tool">
|
|
<button
|
|
type="button"
|
|
className="st-tool-head"
|
|
aria-expanded={open}
|
|
onClick={() => disclosable && onExpandedChange(!open)}
|
|
>
|
|
<ToolIcon kind={tool.kind} />
|
|
<span className="st-tool-name">{label}</span>
|
|
<span className="st-tool-arg" title={arg}>
|
|
{arg}
|
|
</span>
|
|
{metaParts.length > 0 && (
|
|
<span className={meta ? `st-tool-meta st-tool-meta-${meta.kind}` : "st-tool-meta"}>
|
|
{metaParts.join(" · ")}
|
|
</span>
|
|
)}
|
|
</button>
|
|
{disclosable && (
|
|
<div
|
|
className={open ? "st-tool-out st-disclosure is-expanded" : "st-tool-out st-disclosure"}
|
|
aria-hidden={!open}
|
|
inert={!open}
|
|
>
|
|
<div className="st-disclosure-clip">
|
|
<div className="st-disclosure-inner">
|
|
{/* Built only once opened: a long transcript would otherwise
|
|
render every tool's whole output up front. */}
|
|
{open && <ToolBody tool={tool} content={content} />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// notices and turn footers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function compactNumber(value: number): string {
|
|
if (Math.abs(value) < 1_000) return String(value);
|
|
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
|
|
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
|
|
}
|
|
|
|
export function TurnMetaRow({ meta }: { meta: TurnMeta }) {
|
|
const parts = [formatElapsed(meta.elapsedMs)];
|
|
if (typeof meta.contextDelta === "number") {
|
|
const sign = meta.contextDelta > 0 ? "+" : "";
|
|
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
|
|
} else if (typeof meta.contextTokens === "number") {
|
|
parts.push(`${compactNumber(meta.contextTokens)} context`);
|
|
}
|
|
if (meta.model) parts.push(meta.model);
|
|
if (meta.outcome !== "completed") parts.push(meta.outcome);
|
|
const text = parts.join(" · ");
|
|
return (
|
|
<Copyable
|
|
className={`st-turn-meta st-turn-meta-${meta.outcome}`}
|
|
text={text}
|
|
label="turn details"
|
|
aria-label={`Turn ${meta.outcome}`}
|
|
>
|
|
<span className="st-turn-meta-dot" aria-hidden="true" />
|
|
<span>{text}</span>
|
|
</Copyable>
|
|
);
|
|
}
|
|
|
|
/** The floor presentation docs/STRUCTURED-VIEW.md allows for a `Notice` the
|
|
* client has nothing better to do with: an opaque, collapsed line. */
|
|
export function NoticeRow({ row }: { row: Row }) {
|
|
const text = `[${row.kind}]`;
|
|
return (
|
|
<Copyable className="st-row st-row-notice" text={text} label="notice" data-row-id={row.id}>
|
|
<div className="st-body">{text}</div>
|
|
</Copyable>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TranscriptRowProps {
|
|
row: Row;
|
|
/** This row is the one live approval (docs/STRUCTURED-VIEW.md's nonce
|
|
* rules). Every other approval row is history. */
|
|
pendingApproval?: boolean;
|
|
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
|
expanded?: boolean;
|
|
onExpandedChange?: (expanded: boolean) => void;
|
|
imageUrls?: Map<string, string>;
|
|
}
|
|
|
|
/** One transcript row, or `null` for the rows the web client omits (`plan`,
|
|
* and the state-only notices it consumes for `/` autocomplete). */
|
|
export function TranscriptRow({
|
|
row,
|
|
pendingApproval = false,
|
|
onDecide,
|
|
expanded = false,
|
|
onExpandedChange,
|
|
imageUrls,
|
|
}: TranscriptRowProps) {
|
|
switch (row.kind) {
|
|
case "user":
|
|
return <ContentRow row={row} variant="user" imageUrls={imageUrls} />;
|
|
case "agent_text":
|
|
return <ContentRow row={row} variant="agent" />;
|
|
case "agent_thought":
|
|
return <ContentRow row={row} variant="thought" />;
|
|
case "tool_call":
|
|
case "tool_diff":
|
|
return (
|
|
<ToolRow row={row} expanded={expanded} onExpandedChange={(next) => onExpandedChange?.(next)} />
|
|
);
|
|
case "approval": {
|
|
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
|
return (
|
|
<ApprovalView
|
|
request={request}
|
|
pending={pendingApproval}
|
|
onDecide={onDecide ?? (() => Promise.resolve())}
|
|
/>
|
|
);
|
|
}
|
|
case "plan":
|
|
return null;
|
|
case "notice": {
|
|
if (isStateOnlyNotice(row)) return null;
|
|
const meta = turnMeta(row);
|
|
return meta ? <TurnMetaRow meta={meta} /> : <NoticeRow row={row} />;
|
|
}
|
|
default:
|
|
return <NoticeRow row={row} />;
|
|
}
|
|
}
|