Tool call trace Reads run. Writes ask first, and the approval shows the actual payload rather than a description of the intent. A read that failed stays in the trace, because the answer that proceeded around it depends on knowing that. npx shadcn@latest add collapsible button npm i lucide-react Tokens this needs: --card, --card-foreground, --primary, --primary-foreground, --muted, --muted-foreground, --border, --status-warn, --chart-1 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // ToolCallTrace.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { ChevronRight } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type Call = { id: string; /** The tool in plain language, with the arguments that matter. This is the * line most viewers read, and a query that reads nothing like the question * is a misunderstanding caught in the act. */ label: string; /** A read changes nothing and runs freely. A write changes the world and * cannot be undone by any interface once it has run, so it asks first. */ kind: "read" | "write"; /** The actual request. Behind the row for a read; up front for a write * awaiting approval, because approving "send email" tells nobody anything. */ request: Record; outcome: | { status: "pending" } | { status: "running" } | { status: "ok"; elapsedMs: number; response: string } | { status: "failed"; elapsedMs?: number; error: string }; }; type Props = { calls: Call[]; onApprove: (id: string) => void; onEdit: (id: string) => void; }; const seconds = (ms: number) => `${(ms / 1000).toFixed(1)}s`; function Row({ call }: { call: Call }) { const [open, setOpen] = useState(false); const o = call.outcome; const failed = o.status === "failed"; const right = o.status === "ok" ? `${seconds(o.elapsedMs)} · ${call.kind}` : o.status === "failed" ? "failed" : o.status === "running" ? "running…" : "waiting"; return ( {call.label}{failed && ` — ${o.error}`} {right}
          {Object.entries(call.request).map(([k, v]) => `${k}: ${v}\n`).join("")}
          {o.status === "ok" && `→ ${o.response}`}
          {o.status === "failed" && `→ ${o.error}`}
        
); } /** The gate shows the payload, not the intent. The recipient and subject are * what make approving a decision someone can actually make. */ function Gate({ call, onApprove, onEdit }: { call: Call } & Props) { const question = call.label.charAt(0).toUpperCase() + call.label.slice(1) + "?"; return (

{question}

{Object.entries(call.request).map(([k, v]) => (

{`${k}: ${v}`}

))}
); } export function ToolCallTrace({ calls, onApprove, onEdit }: Props) { return (
    {calls.map((call) => (
  1. {call.kind === "write" && call.outcome.status === "pending" ? : }
  2. ))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { ToolCallTrace, type Call } from "./ToolCallTrace"; /** * Three calls. A calendar search that ran, a spreadsheet read that hit a * permission wall and stays in the trace, and an email held behind the gate * with its recipient and subject showing. Approve sends it; the gate becomes * a row like the others. */ const CALLS: Call[] = [ { id: "cal", kind: "read", label: 'searched the calendar for "Tuesday 14:00"', request: { calendar: "work", query: "Tuesday 14:00" }, outcome: { status: "ok", elapsedMs: 400, response: "1 event: Design review, 14:00–14:45, Room 4B" }, }, { id: "q3", kind: "read", label: "read finance/Q3.xlsx", request: { path: "finance/Q3.xlsx", sheet: "summary" }, outcome: { status: "failed", elapsedMs: 120, error: "permission denied" }, }, { id: "mail", kind: "write", label: "send an email", request: { to: "landlord@example.com", subject: "Break clause, 14 March", body: "We intend to exercise the break clause on 14 March and will return the premises with vacant possession…", }, outcome: { status: "pending" }, }, ]; export default function Demo() { const [calls, setCalls] = useState(CALLS); const approve = (id: string) => setCalls(calls.map((c) => (c.id === id ? { ...c, outcome: { status: "ok", elapsedMs: 900, response: "sent" } } : c))); // A real app hands the payload back to the composer here. const edit = () => {}; return (
); }