diff --git a/CLAUDE.md b/CLAUDE.md
index 595b26a..18211d5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -201,15 +201,27 @@ Instructions loaded on demand, in the standard `SKILL.md` format — full detail
under `agent_model_settings`). The active thread is NOT in a context — it is derived from the URL
by `useActiveThreadId()`, so there is one source of truth.
- **Custom Hooks**: `useChatThread`, `useMCPTools`, `useThreads` for data domains
-- **Message Components**: Separate components for AI/Human/Tool/Error message types
+- **Message Components**: `AIMessage` (prose only — tool calls riding on it are rendered by
+ `ToolActivityGroup`, not here), `HumanMessage`, `ErrorMessage`
- **Tool rendering** (`src/components/toolRenderers/`): `config.ts` maps each **built-in** tool to a
view for its **arguments** and its **result** (`sql`, `table`, `receipt`, field grids); anything
not listed — every MCP tool, anything added later — falls back to `json`. A closed catalog with an
open fallback: the payload SELECTS a client-owned renderer, it never describes one. Keyed on tool
name rather than sniffed from the payload, because sniffing predicates are a second contract that
drifts. `config.test.ts` pins every key to a really-registered tool, so a rename can't silently
- drop a tool back to JSON. A tool call's args render in `ToolCallDisplay`, its result in
- `ToolMessage` — two different renders of one operation.
+ drop a tool back to JSON.
+- **A call and its result render as ONE card** (`ToolActivityGroup.tsx`). The wire delivers them as
+ two messages — the call on an AI message, the result as its own `tool` message — and the template
+ drew two cards for them. `buildThreadItems()` (`src/services/toolActivity.ts`) pairs them by
+ `tool_call_id` (never by adjacency: results arrive when they resolve, and a paused call never gets
+ one) and groups CONSECUTIVE calls into one card. Collapsed by default.
+ - **Header label**: a single call gets its own gist (`run_sql · 37 rows`); several get a generic
+ `N tools called`, because no summary spans differently-shaped payloads. `summarizeResult` takes
+ the tool name so `ok: true` reads as "written" only for a **mutating** tool.
+ - **Two things are never collapsed**: a gated call's ARGUMENTS (you cannot approve what you cannot
+ read) and a CHART (it is the answer, not a detail — and it renders with no JSON receipt under
+ it). A drawn chart leaves the collapsed list entirely.
+ - There is no global "hide tools" toggle any more; collapsing per card replaced it.
- **Design tokens** (`src/app/globals.css`): paper ground / ink text / one amber `--brand`, mapped
ONTO shadcn's semantic names so `ui/*` inherits them. Amber marks the approval boundary and
nothing else. `--muted-foreground` must stay ≥4.5:1 on paper (`--faint` is the decorative-only
@@ -297,7 +309,7 @@ Instructions loaded on demand, in the standard `SKILL.md` format — full detail
the SSE contract is otherwise unchanged. Position in the message list is NOT a pending signal:
tool results and later AI text both arrive after the call, so "is it last?" is wrong in both
directions — it hid the gate on a real pause and offered APPROVE on read-only tools.
-- `ToolCallDisplay` additionally requires `isMutatingTool(name)` before rendering the amber panel.
+- `ToolActivityGroup` additionally requires `isMutatingTool(name)` before rendering the amber panel.
Two independent conditions, because that panel claims "this writes to your data".
- `src/services/agentService.ts` translates the wire signal into a HITL resume: it reads the pending
request via `agent.graph.getState()` and resumes with `Command({ resume: { decisions } })` — one
diff --git a/src/components/AIMessage.tsx b/src/components/AIMessage.tsx
index c03dce4..97b1393 100644
--- a/src/components/AIMessage.tsx
+++ b/src/components/AIMessage.tsx
@@ -1,34 +1,22 @@
-import type { MessageResponse, ToolApprovalCallbacks } from "@/types/message";
+import type { MessageResponse } from "@/types/message";
import { Bot } from "lucide-react";
import rehypeKatex from "rehype-katex";
import { cn } from "@/lib/utils";
-import {
- getMessageContent,
- hasToolCalls,
- getToolCalls,
- getPendingToolCallIds,
-} from "@/services/messageUtils";
-import { ToolCallDisplay } from "./ToolCallDisplay";
-import { useUISettings } from "@/contexts/UISettingsContext";
+import { getMessageContent } from "@/services/messageUtils";
import MDEditor from "@uiw/react-md-editor";
interface AIMessageProps {
message: MessageResponse;
- approvalCallbacks?: ToolApprovalCallbacks;
}
-export const AIMessage = ({ message, approvalCallbacks }: AIMessageProps) => {
+/**
+ * The agent's prose. Tool calls ride along on these messages but are NOT rendered here — they are
+ * paired with their results and drawn by ToolActivityGroup, so one operation is one card.
+ */
+export const AIMessage = ({ message }: AIMessageProps) => {
const messageContent = getMessageContent(message);
- const hasTools = hasToolCalls(message);
- const toolCalls = getToolCalls(message);
- const pendingToolCallIds = getPendingToolCallIds(message);
- const { hideToolMessages } = useUISettings();
- // If tool messages are hidden and there's no text content, don't render anything
- const shouldShowTools = hasTools && !hideToolMessages;
- const hasVisibleContent = messageContent || shouldShowTools;
-
- if (!hasVisibleContent) {
+ if (!messageContent) {
return null;
}
@@ -37,38 +25,24 @@ export const AIMessage = ({ message, approvalCallbacks }: AIMessageProps) => {
- {/* Prose stays in a readable column; tool cards run full width so a call lines up with
- the result card that follows it as a sibling message. */}
+ ),
+ },
+);
interface MessageListProps {
messages: MessageResponse[];
@@ -23,7 +25,6 @@ interface MessageListProps {
const MessageList = ({ messages, approveToolExecution }: MessageListProps) => {
const bottomRef = useRef(null);
- const { hideToolMessages } = useUISettings();
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -36,32 +37,30 @@ const MessageList = ({ messages, approveToolExecution }: MessageListProps) => {
onDeny: (toolCallId: string) => approveToolExecution(toolCallId, "deny"),
}
: undefined;
- // Deduplicate by type+id: ids are only unique WITHIN a type (an AI message's synthetic id and a
- // tool message's call id come from different namespaces), so keying on id alone can drop a
- // distinct message. Messages without an id are always kept — they cannot be compared.
- const seen = new Set();
- const uniqueMessages = messages.filter((message) => {
- const id = message.data?.id;
- if (!id) return true;
- const key = `${message.type}:${id}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
+
+ // Dedup, plus pairing of each call with its result; see services/toolActivity.ts.
+ const items = useMemo(() => buildThreadItems(messages), [messages]);
return (
-
- {uniqueMessages.map((message, index) => {
- // Key by type+id for the same reason the dedup does; index backs up an id-less message.
- const key = `${message.type}:${getMessageId(message) || index}`;
+
+ {/* Radix renders its viewport as `display: table; min-width: 100%`, and a table sizes
+ to its widest content — so a wide tool payload stretches the whole thread column
+ instead of scrolling inside its own box. Forcing the inner table to `block` keeps
+ the column at the viewport width. */}
+
+
diff --git a/src/components/ToolActivityGroup.tsx b/src/components/ToolActivityGroup.tsx
new file mode 100644
index 0000000..01875c6
--- /dev/null
+++ b/src/components/ToolActivityGroup.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import React, { useState } from "react";
+import { ChevronDown, ChevronRight, Check, X, Lock } from "lucide-react";
+import type { ToolApprovalCallbacks } from "@/types/message";
+import type { ToolActivity } from "@/services/toolActivity";
+import { summarizeActivities, summarizeResult } from "@/services/toolSummary";
+import { ToolArgs, ToolResult, renderersFor } from "./toolRenderers";
+import { Chart } from "./charts/Chart";
+import type { ChartPayload } from "./charts/types";
+
+/**
+ * One card for a run of tool calls. A call and its result are ONE operation, so they render
+ * together — the split across two components was inherited from the template and cost two
+ * full-width cards per call.
+ *
+ * Collapsed by default: for most tools the arguments and the payload are machinery, not answers.
+ * Two things are never hidden, because hiding them would hide the product:
+ * - a GATED call's arguments — you cannot approve what you cannot read;
+ * - a CHART — it is the answer itself, not a detail of one.
+ */
+
+const isChartPayload = (v: unknown): v is ChartPayload => {
+ const p = v as ChartPayload | undefined;
+ return !!p && typeof p === "object" && !!p.spec && Array.isArray(p.rows);
+};
+
+const contentOf = (activity: ToolActivity): string => {
+ const content = activity.result?.data?.content;
+ if (!content) return "";
+ return typeof content === "string" ? content : JSON.stringify(content, null, 2);
+};
+
+/** The chart an activity should draw outside the collapse, if any. */
+function chartOf(activity: ToolActivity): ChartPayload | null {
+ if (renderersFor(activity.name).result !== "chart") return null;
+ const artifact = (activity.result?.data as { artifact?: unknown } | undefined)?.artifact;
+ return isChartPayload(artifact) ? artifact : null;
+}
+
+/** The amber approval gate. The one place amber appears: it means "this touches money". */
+const ApprovalGate = ({
+ activity,
+ callbacks,
+}: {
+ activity: ToolActivity;
+ callbacks: ToolApprovalCallbacks;
+}) => {
+ const [responded, setResponded] = useState(false);
+
+ return (
+
+
+
+
+ APPROVAL REQUIRED
+
+
+ {activity.name}
+
+
+
+
+
+ Cameron wants to run a tool that{" "}
+ writes to your data.
+
+
+ {/* Never collapsed — the arguments ARE the thing being approved. */}
+
+
+
+
+
+
+ nothing is written until you approve
+
+
+
+
+ );
+};
+
+/** The expanded detail for one call: its arguments, then whatever came back. */
+const ActivityDetail = ({ activity }: { activity: ToolActivity }) => {
+ const content = contentOf(activity);
+ const gist = activity.result ? summarizeResult(content, activity.name) : null;
+
+ return (
+
+ );
+};
+
+interface ToolActivityGroupProps {
+ activities: ToolActivity[];
+ approvalCallbacks?: ToolApprovalCallbacks;
+}
+
+export const ToolActivityGroupCard = ({
+ activities,
+ approvalCallbacks,
+}: ToolActivityGroupProps) => {
+ const [open, setOpen] = useState(false);
+
+ // Charts and gates escape the collapse; everything else lives behind the toggle. A drawn chart
+ // leaves the collapsed list entirely — showing "render_chart · 5 rows" above its own chart is
+ // the duplication this refactor removes.
+ const charts = activities
+ .map((activity) => ({ activity, chart: chartOf(activity) }))
+ .filter((c): c is { activity: ToolActivity; chart: ChartPayload } => c.chart !== null);
+ const drawn = new Set(charts.map((c) => c.activity.callId));
+ const isGated = (a: ToolActivity) => a.isPending && a.isMutating;
+ const gated = approvalCallbacks ? activities.filter(isGated) : [];
+ const collapsible = activities.filter((a) => !isGated(a) && !drawn.has(a.callId));
+
+ return (
+ // w-full + min-w-0: the card is a direct child of the message list now (it used to sit inside
+ // AIMessage's flex column), so nothing else constrains it — without this a wide payload widens
+ // the whole thread column.
+
+ {collapsible.length > 0 && (
+
+
+
+ {/* min-w-0 + overflow-hidden: without them a wide SQL block or JSON dump stretches the
+ card and scrolls the whole PAGE sideways instead of scrolling inside its own box. */}
+ {open && (
+
+ {collapsible.map((activity) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* A chart is the answer — always visible, never behind the toggle, and with no JSON
+ receipt underneath it. */}
+ {charts.map(({ activity, chart }) => (
+
+ ))}
+
+ {gated.map((activity) => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/ToolCallDisplay.tsx b/src/components/ToolCallDisplay.tsx
deleted file mode 100644
index 7dca7d0..0000000
--- a/src/components/ToolCallDisplay.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import React, { useState } from "react";
-import { ChevronDown, ChevronRight, Check, X, Lock } from "lucide-react";
-import type { ToolCall, FunctionCall, ToolApprovalCallbacks } from "@/types/message";
-import { isMutatingTool } from "@/lib/agent/mutatingTools";
-import { ToolArgs } from "./toolRenderers";
-
-interface ToolCallDisplayProps {
- toolCalls?: ToolCall[];
- functionCalls?: FunctionCall[];
- approvalCallbacks?: ToolApprovalCallbacks;
- /** Ids the server reported as paused by the gate. Nothing else may open the approval panel. */
- pendingToolCallIds?: string[];
-}
-
-const renderArgs = (name: string, args: Record | string) => {
- const parsed = typeof args === "string" ? (JSON.parse(args) as Record) : args;
- return ;
-};
-
-const ToolCallItem: React.FC<{
- name: string;
- args: Record;
- id?: string;
- approvalCallbacks?: ToolApprovalCallbacks;
- isPending?: boolean;
-}> = ({ name, args, id, approvalCallbacks, isPending }) => {
- const [isExpanded, setIsExpanded] = useState(false);
- const [responded, setResponded] = useState(false);
-
- // Two independent conditions, both required. `isPending` is the graph's actual paused state;
- // `isMutatingTool` is the boundary itself. A read-only tool must never render the gate even if
- // the server somehow reports it pending — the amber panel claims "this writes to your data".
- const isPendingApproval = Boolean(
- isPending && isMutatingTool(name) && id && approvalCallbacks && !responded,
- );
-
- // A gate awaiting a decision is the one place amber appears: it means "this touches money".
- if (isPendingApproval) {
- return (
-
-
-
-
- APPROVAL REQUIRED
-
-
- {name}
-
-
-
-
-
- Cameron wants to run a tool that{" "}
- writes to your data.
-
-
- {renderArgs(name, args)}
-
-
-
-
-
- nothing is written until you approve
-
-
-
-
- );
- }
-
- const isMutating = isMutatingTool(name);
-
- return (
- /* Same shell as ToolMessage: a call and its result are one operation and should read
- as one family, even though they arrive as two messages. */
-