feat(chat): collapse runs of identical tool calls into a group#55
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #35.
Bulk operations — a
gds/content-updaterun of 12 menu_order edits, agds/forms-getaudit 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: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:toolNameupdate {title}andupdate {menu_order}stay separate; values are free to differ)running/done/error/pending-approval) — a pending-approval card never folds into theDonegroup above itRenderer
Messages.tsx'sAssistantMessageused to delegate toMessagePrimitive.Contentwith a singletools.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 viauseMessage, runsgroupAdjacentToolCalls()(memo'd on parts +pendingApprovalIds), and dispatches ontypeto<ToolCallGroup>/<ToolCallFallback>/<AssistantMessageText>/<MessageImage>.User-message rendering is unchanged.
Files
components/tool-call-grouping.ts(new)groupAdjacentToolCalls,ToolCallGroup,ToolCallStatus.components/__tests__/tool-call-grouping.test.ts(new)minGroupSize, etc.).components/ToolCallFallback.tsx<ToolCallGroup>sits next to the existing fallback; shares state lookup (UndoContext) via the per-call<ToolCallFallback>it renders.components/Messages.tsx<AssistantContent>replacesMessagePrimitive.ContentinAssistantMessage.styles/admin-chat.css--error/--pending-approvaltint.Verification
npm run typecheck— cleannpm run lint:js— 0 errors, 0 warningsnpm run build— succeedsnpm run test:unit— 27/27 (10 new + 17 carried)🤖 Generated with Claude Code