Skip to content

feat(chat): collapse runs of identical tool calls into a group#55

Merged
oxyc merged 2 commits into
mainfrom
feat/tool-call-grouping
Jun 1, 2026
Merged

feat(chat): collapse runs of identical tool calls into a group#55
oxyc merged 2 commits into
mainfrom
feat/tool-call-grouping

Conversation

@oxyc

@oxyc oxyc commented Jun 1, 2026

Copy link
Copy Markdown
Member

Closes #35.

Bulk operations — a gds/content-update run of 12 menu_order edits, a gds/forms-get audit across 20 forms — used to fill the chat with identical tool-call cards. Now adjacent runs of same-shape calls fold into one expandable header:

▶ gds/content-update × 12 [Done (12)]

Click to expand and each call renders as its own <ToolCallFallback> with per-call diff, Undo button, Retry, and approval state intact. The group is a folder, not a replacement.

Grouping rules

Codified in tool-call-grouping.ts + tested in __tests__/tool-call-grouping.test.ts:

  • Same toolName
  • Same SET of arg keys (so update {title} and update {menu_order} stay separate; values are free to differ)
  • Same status bucket (running / done / error / pending-approval) — a pending-approval card never folds into the Done group above it
  • Adjacent in the parts array (a text/image part breaks the run)
  • Minimum group size: 3 (so two of a kind stay solo)

Renderer

Messages.tsx's AssistantMessage used to delegate to MessagePrimitive.Content with a single tools.Fallback. That hook dispatches per individual part, so it can't emit a synthetic "tool-call-group" item. Replaced with a custom <AssistantContent> that reads parts via useMessage, runs groupAdjacentToolCalls() (memo'd on parts + pendingApprovalIds), and dispatches on type to <ToolCallGroup> / <ToolCallFallback> / <AssistantMessageText> / <MessageImage>.

User-message rendering is unchanged.

Files

File Notes
components/tool-call-grouping.ts (new) Pure helper. Exports groupAdjacentToolCalls, ToolCallGroup, ToolCallStatus.
components/__tests__/tool-call-grouping.test.ts (new) 10 unit tests covering each spec rule (solo below threshold, different shapes don't group, text part breaks runs, approval-status split, mid-run error splits, custom minGroupSize, etc.).
components/ToolCallFallback.tsx New <ToolCallGroup> sits next to the existing fallback; shares state lookup (UndoContext) via the per-call <ToolCallFallback> it renders.
components/Messages.tsx New <AssistantContent> replaces MessagePrimitive.Content in AssistantMessage.
styles/admin-chat.css Visual treatment — lower weight than a solo card so bulk runs read as one line; modifier classes for --error / --pending-approval tint.

Verification

  • npm run typecheck — clean
  • npm run lint:js0 errors, 0 warnings
  • npm run build — succeeds
  • npm run test:unit — 27/27 (10 new + 17 carried)

🤖 Generated with Claude Code

oxyc and others added 2 commits June 1, 2026 12:17
Bulk operations — a `gds/content-update` of 12 menu_order edits, a
`gds/forms-get` audit across 20 forms — used to produce a wall of
identical tool-call cards in the chat. The user had to scroll past all
of them to reach the assistant's confirmation. Now adjacent runs of
same-shape calls fold into one expandable header:

  ▶ gds/content-update × 12 [Done (12)]

Click to expand and each call renders as its own `<ToolCallFallback>`
with per-call diff, Undo button, Retry, and approval state intact —
the group is a folder, not a replacement.

## Grouping rules

Codified in `tool-call-grouping.ts` + tested in
`__tests__/tool-call-grouping.test.ts`:

- Same `toolName`
- Same SET of arg keys (so `update {title}` and `update {menu_order}`
  stay separate; values are free to differ)
- Same status bucket (running / done / error / pending-approval) — a
  pending-approval card never folds into the `Done` group above it
- Adjacent in the parts array (a text/image part breaks the run)
- Minimum group size: 3 (so two of a kind stay solo)

## Renderer

`Messages.tsx`'s `AssistantMessage` used to delegate to
`MessagePrimitive.Content` with a single `tools.Fallback`. That hook
dispatches per individual part, so it can't emit a synthetic
"tool-call-group" item. Replaced with a custom `<AssistantContent>`
that:

  1. Reads parts via `useMessage((s) => s.content)`
  2. Runs `groupAdjacentToolCalls()` (memo'd on parts + pendingApprovalIds)
  3. Iterates the result, dispatching on `type` to `<ToolCallGroup>`,
     `<ToolCallFallback>`, `<AssistantMessageText>`, or `<MessageImage>`

User-message rendering is unchanged — text-only.

## New files

- `components/tool-call-grouping.ts` — pure helper. Exports
  `groupAdjacentToolCalls`, the `ToolCallGroup` shape, and
  `ToolCallStatus` ("running" / "done" / "error" / "pending-approval").
- `components/__tests__/tool-call-grouping.test.ts` — 10 unit tests
  covering each spec rule (below-threshold solo, different shapes don't
  group, text part breaks runs, approval-status split, mid-run error
  splits, custom minGroupSize, etc.).
- `components/ToolCallFallback.tsx` — added `<ToolCallGroup>` next to
  the existing fallback; it shares state lookup (UndoContext) with the
  individual cards via the per-call `<ToolCallFallback>` it renders.
- `styles/admin-chat.css` — visual: lower-weight than a solo card so a
  long bulk run reads as one line; modifier classes for error /
  pending-approval tint.

## Verification

- `npm run typecheck` — clean
- `npm run lint:js` — 0 errors, 0 warnings
- `npm run build` — succeeds
- `npm run test:unit` — 27/27 (10 new + 17 carried)

Closes #35.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Initial implementation replaced `MessagePrimitive.Content` with a custom
`<AssistantContent>` to gain per-part control. That broke the e2e tests
(approval bar never appeared, etc.) because the library tracks parts
through its Content primitive — bypassing it skipped the runtime's
"message rendered" wiring that the streaming pipeline depends on.

Switched to the library's built-in `components.ToolGroup` slot
(MessageParts.d.ts shows it as the supported extension point for
grouping consecutive tool-call parts). The library decides what a
"run" is (any consecutive tool calls in one message); our
`<SmartToolGroup>` decides how to present it:

  - Same shape + same status bucket + size >= 3 → collapse into
    `<ToolCallGroup>` (the #35 outcome).
  - Anything else → render the library-supplied children inline so
    individual cards still appear, no behaviour change.

Same grouping rules; same `tool-call-grouping.ts` helper; same
`<ToolCallGroup>` component. Only the integration point changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oxyc oxyc merged commit 25aed07 into main Jun 1, 2026
3 checks passed
@oxyc oxyc deleted the feat/tool-call-grouping branch June 1, 2026 15:55
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.

Tool-call grouping: collapse runs of identical tool calls in the thread

1 participant