Skip to content

refactor(ui): render a tool call and its result as one collapsible card - #32

Merged
IBJunior merged 1 commit into
mainfrom
refactor/unified-tool-activity
Sep 13, 2026
Merged

IBJunior merged 1 commit into
mainfrom
refactor/unified-tool-activity

Conversation

@IBJunior

Copy link
Copy Markdown
Member

The tool call / result rendering was inherited from the template and had three problems: a call and its result were drawn as two separate full-width cards, most tools showed arguments and payloads nobody needs to read, and render_chart drew its chart with a JSON receipt stuck underneath it.

One card per operation

The wire delivers a call and its result as two messages — the call on an AI message, the result as its own tool message — which is why the UI drew two. buildThreadItems() (src/services/toolActivity.ts) pairs them by tool_call_id and groups consecutive calls into one card.

Pairing is by id, never by adjacency. Results arrive when they resolve, and a call paused at the approval gate never gets a result at all — adjacency would look right in the common case and break exactly where it matters.

Collapsed by default, one toggle to open.

The header label

  • One call → its own gist: load_skill · ok, run_sql · 37 rows
  • SeveralN tools called

Counting is the only honest claim across a run: the payloads have different shapes and no summary spans them.

While wiring this up I found summarizeResult mapped any ok: true to "written", so read-only load_skill claimed a side effect it never had. It now takes the tool name and says "written" only for a mutating tool. In a finance agent that word is load-bearing.

The two exceptions

  • A gated call's arguments are never collapsed. You cannot approve what you cannot read.
  • A chart is never collapsed, and carries no JSON receipt. It is the answer, not a detail of one — the chart activity leaves the collapsed list entirely rather than showing render_chart · … above its own chart.

hide tools removed

The button, the hideToolMessages state, and toggleToolMessages — not just the UI. Per-card collapsing replaced it.

Two fixes found while building this

Dedup merges rather than discards. The pending-approval marker (pendingToolCallIds, from #31) arrives as a chunk bearing the calling message's id. The first version of the dedup dropped it, silently throwing the approval gate away. Repeats are now folded into the message already kept. Caught by a test, not by hand.

Thread column width. Tool cards used to sit inside AIMessage's min-w-0 column; as direct children of the list nothing constrained them. Root cause was Radix rendering its scroll viewport as display: table, which sizes to its widest content — so a wide JSON payload stretched the whole column instead of scrolling in its own box. Fixed scoped to Thread.tsx; the shared ui/scroll-area.tsx primitive is untouched. Measured: expanded card right edge 1040 → 884, matching collapsed exactly.

Verification

  • 213 tests pass (20 new in toolActivity.test.ts), tsc --noEmit clean, production build succeeds, prettier clean.
  • Tested against both real captured SSE streams and real reloaded history. The two shapes differ, and that difference caught a bug: history stores content as an array containing tool_call blocks, so an array-length check counted a calls-only message as text and split the group. Both shapes are now pinned by tests.
  • Full gate cycle live: two mutating tools (set_config + log_expense) paused together → both gated with arguments shown → approved → both ran, zero stale pending chunks, row written.
  • Dev stack only (mydb_dev:5544, port 3101). Production untouched; no rows deleted.

Net: -421 lines, +782 (the additions are mostly the new tests and the extracted pairing module; ToolCallDisplay.tsx and ToolMessage.tsx are deleted).

🤖 Generated with Claude Code

The template drew a call and its result as two full-width cards, because
the wire delivers them as two messages — the call on an AI message, the
result as its own `tool` message. One operation now renders as one card.

`buildThreadItems()` pairs them by `tool_call_id` and groups consecutive
calls. Pairing is never by adjacency: results arrive when they resolve,
and a call paused at the approval gate never gets one at all.

Collapsed by default. The header names a single call with its own gist
(`run_sql · 37 rows`) and falls back to `N tools called` for a run of
them, since no summary spans differently-shaped payloads.

Two things are never collapsed, because hiding them would hide the
product: a gated call's ARGUMENTS — you cannot approve what you cannot
read — and a CHART, which is the answer rather than a detail of one, and
which no longer carries a JSON receipt underneath it.

`summarizeResult` now takes the tool name: `ok: true` read as "written"
for every tool, so read-only `load_skill` claimed a write it never made.

Drops the global hide-tools toggle — its state, its context entry and its
button — now that each card collapses on its own.

Two fixes found while building it. Dedup MERGES repeated messages rather
than discarding them: the pending-approval marker arrives as a chunk
bearing the calling message's id, so dropping it threw the gate away.
And the cards are direct children of the list now, where nothing
constrained their width — Radix renders its scroll viewport as
`display: table`, which sizes to its widest content, so a wide payload
stretched the whole thread column instead of scrolling in its own box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@IBJunior
IBJunior merged commit 6574125 into main Sep 13, 2026
1 check passed
@IBJunior
IBJunior deleted the refactor/unified-tool-activity branch September 13, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant