{text},
+ tools: { Fallback: ToolCallRenderer },
+ ToolGroup: ToolGroup,
+ }}
/>`,
};
},
diff --git a/apps/docs/components/playground/controls-panel.tsx b/apps/docs/components/playground/controls-panel.tsx
index 0391843..bfefde9 100644
--- a/apps/docs/components/playground/controls-panel.tsx
+++ b/apps/docs/components/playground/controls-panel.tsx
@@ -15,6 +15,7 @@ import { ScrollToBottomControls } from "./controls/scroll-to-bottom-controls";
import { ReasoningAccordionControls } from "./controls/reasoning-accordion-controls";
import { FeedbackButtonsControls } from "./controls/feedback-buttons-controls";
import { ThreadListControls } from "./controls/thread-list-controls";
+import { ToolGroupControls } from "./controls/tool-group-controls";
type ControlsPanelProps = {
chordId: ChordId;
@@ -39,6 +40,7 @@ const controlsMap: Partial<
"reasoning-accordion": ReasoningAccordionControls,
"feedback-buttons": FeedbackButtonsControls,
"thread-list": ThreadListControls,
+ "tool-group": ToolGroupControls,
};
export function ControlsPanel({ chordId, config, onChange }: ControlsPanelProps) {
diff --git a/apps/docs/components/playground/controls/tool-group-controls.tsx b/apps/docs/components/playground/controls/tool-group-controls.tsx
new file mode 100644
index 0000000..527aaa1
--- /dev/null
+++ b/apps/docs/components/playground/controls/tool-group-controls.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import type { ChordConfig } from "@/lib/playground/types";
+import { StyleBuilder, CheckboxInput } from "./shared";
+
+export function ToolGroupControls({
+ config,
+ onChange,
+}: {
+ config: ChordConfig;
+ onChange: (c: ChordConfig) => void;
+}) {
+ return (
+
+ onChange({ ...config, defaultOpen: v })}
+ />
+ onChange({ ...config, className: v || undefined })}
+ controls={["bg", "rounded", "p"]}
+ />
+
+ onChange({ ...config, triggerClassName: v || undefined })
+ }
+ controls={["bg", "text"]}
+ />
+
+ onChange({ ...config, contentClassName: v || undefined })
+ }
+ controls={["bg", "text"]}
+ />
+
+ );
+}
diff --git a/apps/docs/components/playground/preview-panel.tsx b/apps/docs/components/playground/preview-panel.tsx
index 6aacf47..79d52b9 100644
--- a/apps/docs/components/playground/preview-panel.tsx
+++ b/apps/docs/components/playground/preview-panel.tsx
@@ -20,6 +20,7 @@ import { ScrollToBottomPreview } from "./previews/scroll-to-bottom-preview";
import { ReasoningAccordionPreview } from "./previews/reasoning-accordion-preview";
import { FeedbackButtonsPreview } from "./previews/feedback-buttons-preview";
import { ThreadListPreview } from "./previews/thread-list-preview";
+import { ToolGroupPreview } from "./previews/tool-group-preview";
type PreviewPanelProps = {
chordId: ChordId;
@@ -42,6 +43,7 @@ const previewMap: Record> = {
"reasoning-accordion": ReasoningAccordionPreview,
"feedback-buttons": FeedbackButtonsPreview,
"thread-list": ThreadListPreview,
+ "tool-group": ToolGroupPreview,
};
export function PreviewPanel({ chordId, config }: PreviewPanelProps) {
diff --git a/apps/docs/components/playground/previews/tool-group-preview.tsx b/apps/docs/components/playground/previews/tool-group-preview.tsx
new file mode 100644
index 0000000..7e122c5
--- /dev/null
+++ b/apps/docs/components/playground/previews/tool-group-preview.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import {
+ AssistantRuntimeProvider,
+ ThreadPrimitive,
+ MessagePrimitive,
+ ComposerPrimitive,
+ useLocalRuntime,
+} from "@assistant-ui/react";
+import {
+ ToolCallRenderer,
+ ToolGroup,
+ ComposerActionStatus,
+} from "@assistant-ui/chords";
+import type { ChordConfig } from "@/lib/playground/types";
+import type { ChatModelAdapter } from "@assistant-ui/react";
+import { useRef } from "react";
+
+const toolAdapter: ChatModelAdapter = {
+ async *run({ abortSignal }) {
+ const id1 = `call_${Date.now()}_1`;
+ const id2 = `call_${Date.now()}_2`;
+
+ const tc1 = {
+ type: "tool-call" as const,
+ toolCallId: id1,
+ toolName: "search",
+ args: { query: "hello" },
+ argsText: JSON.stringify({ query: "hello" }),
+ };
+ const tc2 = {
+ type: "tool-call" as const,
+ toolCallId: id2,
+ toolName: "fetch",
+ args: { url: "https://example.com" },
+ argsText: JSON.stringify({ url: "https://example.com" }),
+ };
+
+ yield { content: [tc1] };
+ yield { content: [tc1, tc2] };
+
+ await new Promise((r) => setTimeout(r, 1200));
+ if (abortSignal.aborted) return;
+
+ yield {
+ content: [
+ { ...tc1, result: { hits: 42 } },
+ { ...tc2, result: { status: 200 } },
+ {
+ type: "text" as const,
+ text: "Found 42 results. The page returned status 200.",
+ },
+ ],
+ };
+ },
+};
+
+export function ToolGroupPreview({ config }: { config: ChordConfig }) {
+ const adapter = useRef(toolAdapter);
+ const runtime = useLocalRuntime(adapter.current);
+
+ return (
+
+
+
+
+
+ (
+
+
+
+
+
+ ),
+ AssistantMessage: () => (
+
+
+ A
+
+
+ {text},
+ tools: { Fallback: ToolCallRenderer },
+ ToolGroup: (props) => (
+
+ ),
+ }}
+ />
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+ Run tool calls
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/content/docs/major-chords/meta.json b/apps/docs/content/docs/major-chords/meta.json
index c4f6d49..9f590f2 100644
--- a/apps/docs/content/docs/major-chords/meta.json
+++ b/apps/docs/content/docs/major-chords/meta.json
@@ -11,6 +11,7 @@
"tool-call-renderer",
"reasoning-accordion",
"feedback-buttons",
- "thread-list"
+ "thread-list",
+ "tool-group"
]
}
diff --git a/apps/docs/content/docs/major-chords/tool-group.mdx b/apps/docs/content/docs/major-chords/tool-group.mdx
new file mode 100644
index 0000000..90fb6f2
--- /dev/null
+++ b/apps/docs/content/docs/major-chords/tool-group.mdx
@@ -0,0 +1,64 @@
+---
+title: ToolGroup
+description: Collapsible container that groups consecutive tool calls with a count badge and spinner.
+---
+
+## Overview
+
+`ToolGroup` groups consecutive tool calls into a single collapsible container. It shows a count badge ("3 tool calls"), a spinner during execution, and a shimmer effect while tools are running.
+
+## Demo
+
+
+
+## When to use
+
+- Group multiple consecutive tool calls visually
+- Show a spinner while tools execute
+- Let users expand/collapse tool call details
+
+## Features
+
+- **Auto-grouping**: Automatically receives consecutive tool-call parts from `MessagePrimitive.Parts`
+- **Active detection**: Shows spinner and shimmer when any tool in the group is running
+- **Collapsible**: Click to expand/collapse tool call details
+- **Pluralized label**: "1 tool call" vs "3 tool calls"
+
+## Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `className` | string | — | Root container class |
+| `triggerClassName` | string | — | Trigger row class |
+| `contentClassName` | string | — | Collapsible content area class |
+| `defaultOpen` | boolean | `false` | Whether to start expanded |
+| `label` | `(count: number) => string` | — | Custom label function |
+| `renderTrigger` | `(props) => ReactNode` | — | Custom trigger renderer |
+
+## Basic Usage
+
+```tsx
+import { ToolCallRenderer, ToolGroup } from "@assistant-ui/chords";
+
+ {text},
+ tools: { Fallback: ToolCallRenderer },
+ ToolGroup: ToolGroup,
+ }}
+/>
+```
+
+## With Custom Label
+
+```tsx
+ `${n} function ${n === 1 ? "executed" : "executions"}`}
+/>
+```
+
+## Underlying Primitives
+
+`ToolGroup` is used as the `ToolGroup` component in `MessagePrimitive.Parts`. The framework automatically groups consecutive `tool-call` message parts and passes them as children.
+
+It reads message state via `useAuiState` to detect whether any tool in the group is currently running.
diff --git a/apps/docs/lib/playground/chord-registry.ts b/apps/docs/lib/playground/chord-registry.ts
index 4d7b441..fb46170 100644
--- a/apps/docs/lib/playground/chord-registry.ts
+++ b/apps/docs/lib/playground/chord-registry.ts
@@ -133,6 +133,15 @@ export const chordRegistry: Record = {
actions: ["archive", "delete"],
},
},
+ "tool-group": {
+ id: "tool-group",
+ name: "ToolGroup",
+ category: "major",
+ description: "Collapsible container grouping consecutive tool calls with count badge and spinner",
+ defaultConfig: {
+ defaultOpen: false,
+ },
+ },
"scroll-to-bottom": {
id: "scroll-to-bottom",
name: "ScrollToBottom",
diff --git a/apps/docs/lib/playground/types.ts b/apps/docs/lib/playground/types.ts
index 8a1c6b1..eb84a72 100644
--- a/apps/docs/lib/playground/types.ts
+++ b/apps/docs/lib/playground/types.ts
@@ -14,6 +14,7 @@ export const CHORD_IDS = [
"reasoning-accordion",
"feedback-buttons",
"thread-list",
+ "tool-group",
] as const;
export type ChordId = (typeof CHORD_IDS)[number];
diff --git a/apps/docs/mdx-components.tsx b/apps/docs/mdx-components.tsx
index ba0daf2..fcf3acf 100644
--- a/apps/docs/mdx-components.tsx
+++ b/apps/docs/mdx-components.tsx
@@ -29,6 +29,7 @@ import { ToolCallRendererDemo } from "@/components/demo/tool-call-renderer-demo"
import { ReasoningAccordionDemo } from "@/components/demo/reasoning-accordion-demo";
import { FeedbackButtonsDemo } from "@/components/demo/feedback-buttons-demo";
import { ThreadListDemo } from "@/components/demo/thread-list-demo";
+import { ToolGroupDemo } from "@/components/demo/tool-group-demo";
type Components = Record>;
@@ -63,6 +64,7 @@ export function getMDXComponents(components?: Components): Components {
ReasoningAccordionDemo,
FeedbackButtonsDemo,
ThreadListDemo,
+ ToolGroupDemo,
blockquote: (props: any) => {props.children},
...components,
};
diff --git a/apps/example/app/components/Assistant.tsx b/apps/example/app/components/Assistant.tsx
index 43eda59..5b348be 100644
--- a/apps/example/app/components/Assistant.tsx
+++ b/apps/example/app/components/Assistant.tsx
@@ -26,6 +26,7 @@ import {
ThreadList,
ThreadListItem,
ToolCallRenderer,
+ ToolGroup,
} from "@assistant-ui/chords";
import { mockModelAdapter } from "../lib/mock-model-adapter";
import type { FC } from "react";
@@ -176,6 +177,7 @@ const AssistantMessage: FC = () => {
Text: ({ text }) => {text},
Reasoning: ({ text }) => {text},
ReasoningGroup: ReasoningAccordion,
+ ToolGroup: ToolGroup,
tools: { Fallback: ToolCallRenderer },
}}
/>
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 131f660..3ddce41 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -13,4 +13,5 @@ export * from "./primitives/tool-call-renderer";
export * from "./primitives/reasoning-accordion";
export * from "./primitives/feedback-buttons";
export * from "./primitives/thread-list";
+export * from "./primitives/tool-group";
export * from "./primitives/thread-list-item";
diff --git a/packages/core/src/primitives/tool-group/ToolGroup.tsx b/packages/core/src/primitives/tool-group/ToolGroup.tsx
new file mode 100644
index 0000000..d0f7def
--- /dev/null
+++ b/packages/core/src/primitives/tool-group/ToolGroup.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+import { useAuiState, useScrollLock } from "@assistant-ui/react";
+import {
+ DEFAULT_ROOT_CLASSNAME,
+ DEFAULT_TRIGGER_CLASSNAME,
+ DEFAULT_CONTENT_CLASSNAME,
+ DEFAULT_CHEVRON_CLASSNAME,
+ DEFAULT_SPINNER_CLASSNAME,
+} from "./defaults";
+
+const ANIMATION_DURATION = 200;
+
+export type ToolGroupProps = React.PropsWithChildren<{
+ startIndex: number;
+ endIndex: number;
+ /** Root container class. */
+ className?: string;
+ /** Trigger row class. */
+ triggerClassName?: string;
+ /** Collapsible content area class. */
+ contentClassName?: string;
+ /** Whether the group starts open. @default false */
+ defaultOpen?: boolean;
+ /** Custom label function. @default (n) => `${n} tool call(s)` */
+ label?: (count: number) => string;
+ /** Custom trigger renderer. */
+ renderTrigger?: (props: {
+ count: number;
+ active: boolean;
+ open: boolean;
+ }) => React.ReactNode;
+}>;
+
+/**
+ * Groups consecutive tool calls into a collapsible container with a
+ * count badge and spinner during execution.
+ *
+ * Used as the `ToolGroup` component in `MessagePrimitive.Parts`:
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * Must be rendered inside a message context.
+ */
+export function ToolGroup({
+ children,
+ startIndex,
+ endIndex,
+ className,
+ triggerClassName,
+ contentClassName,
+ defaultOpen = false,
+ label,
+ renderTrigger,
+}: ToolGroupProps) {
+ const toolCount = endIndex - startIndex + 1;
+
+ const isActive = useAuiState((s) => {
+ if (s.message.status?.type !== "running") return false;
+ for (let i = startIndex; i <= endIndex; i++) {
+ const part = s.message.parts[i];
+ if (part?.type === "tool-call" && part.status?.type === "running") {
+ return true;
+ }
+ }
+ return false;
+ });
+
+ const [open, setOpen] = useState(defaultOpen);
+ const contentRef = useRef(null);
+ const lockScroll = useScrollLock(contentRef, ANIMATION_DURATION);
+
+ const handleToggle = useCallback(() => {
+ setOpen((prev) => {
+ if (prev) lockScroll();
+ return !prev;
+ });
+ }, [lockScroll]);
+
+ const labelText = label
+ ? label(toolCount)
+ : `${toolCount} tool ${toolCount === 1 ? "call" : "calls"}`;
+
+ const triggerContent = renderTrigger ? (
+ renderTrigger({ count: toolCount, active: isActive, open })
+ ) : (
+ <>
+ {isActive && }
+
+ {labelText}
+ {isActive && (
+
+ {labelText}
+
+ )}
+
+
+ >
+ );
+
+ return (
+
+
+ {open && (
+
+ )}
+
+ );
+}
+
+ToolGroup.displayName = "ToolGroup";
+
+// --- Inline SVG icons ---
+
+function SpinnerIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function ChevronIcon({
+ className,
+ style,
+}: {
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/core/src/primitives/tool-group/__tests__/ToolGroup.test.tsx b/packages/core/src/primitives/tool-group/__tests__/ToolGroup.test.tsx
new file mode 100644
index 0000000..c446681
--- /dev/null
+++ b/packages/core/src/primitives/tool-group/__tests__/ToolGroup.test.tsx
@@ -0,0 +1,187 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+const mockState = {
+ message: {
+ status: { type: "complete" },
+ parts: [
+ { type: "tool-call", status: { type: "complete" } },
+ { type: "tool-call", status: { type: "complete" } },
+ ],
+ },
+};
+
+vi.mock("@assistant-ui/react", () => ({
+ useAuiState: (selector: any) => selector(mockState),
+ useScrollLock: () => () => {},
+}));
+
+import { ToolGroup } from "../ToolGroup";
+import {
+ DEFAULT_ROOT_CLASSNAME,
+ DEFAULT_TRIGGER_CLASSNAME,
+ DEFAULT_CONTENT_CLASSNAME,
+} from "../defaults";
+
+describe("ToolGroup", () => {
+ it("renders with default class", () => {
+ const { container } = render(
+
+ tool 1
+ tool 2
+ ,
+ );
+ expect(container.firstChild).toHaveClass(
+ ...DEFAULT_ROOT_CLASSNAME.split(" "),
+ );
+ });
+
+ it("renders with custom class", () => {
+ const { container } = render(
+
+ tool
+ ,
+ );
+ expect(container.firstChild).toHaveClass("custom");
+ });
+
+ it("shows correct count label for multiple tools", () => {
+ render(
+
+ tool
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveTextContent("3 tool calls");
+ });
+
+ it("shows correct count label for single tool", () => {
+ render(
+
+ tool
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveTextContent("1 tool call");
+ });
+
+ it("uses custom label function", () => {
+ render(
+ `${n} functions`}
+ >
+ tool
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveTextContent("2 functions");
+ });
+
+ it("children hidden by default", () => {
+ render(
+
+ tool
+ ,
+ );
+ expect(screen.queryByTestId("child")).not.toBeInTheDocument();
+ });
+
+ it("children visible when defaultOpen", () => {
+ render(
+
+ tool
+ ,
+ );
+ expect(screen.getByTestId("child")).toBeInTheDocument();
+ });
+
+ it("toggles on click", () => {
+ render(
+
+ tool
+ ,
+ );
+ expect(screen.queryByTestId("child")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button"));
+ expect(screen.getByTestId("child")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button"));
+ expect(screen.queryByTestId("child")).not.toBeInTheDocument();
+ });
+
+ it("has aria-expanded attribute", () => {
+ render(
+
+ tool
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn).toHaveAttribute("aria-expanded", "false");
+ fireEvent.click(btn);
+ expect(btn).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("uses default trigger class", () => {
+ render(
+
+ tool
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveClass(
+ ...DEFAULT_TRIGGER_CLASSNAME.split(" "),
+ );
+ });
+
+ it("renders custom trigger via renderTrigger", () => {
+ render(
+ (
+ {count} tools
+ )}
+ >
+ tool
+ ,
+ );
+ expect(screen.getByTestId("custom-trigger")).toHaveTextContent("2 tools");
+ });
+
+ it("uses custom triggerClassName", () => {
+ render(
+
+ tool
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveClass("custom-trigger");
+ });
+
+ it("uses custom contentClassName", () => {
+ render(
+
+ tool
+ ,
+ );
+ // content div wraps an inner flex container that holds children
+ const content = screen.getByTestId("child").parentElement!.parentElement;
+ expect(content).toHaveClass("custom-content");
+ });
+
+ it("uses default content class when open", () => {
+ render(
+
+ tool
+ ,
+ );
+ const content = screen.getByTestId("child").parentElement!.parentElement;
+ expect(content).toHaveClass(...DEFAULT_CONTENT_CLASSNAME.split(" "));
+ });
+
+ it("renders chevron icon", () => {
+ render(
+
+ tool
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn.querySelector("svg")).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/primitives/tool-group/defaults.ts b/packages/core/src/primitives/tool-group/defaults.ts
new file mode 100644
index 0000000..3d4fddb
--- /dev/null
+++ b/packages/core/src/primitives/tool-group/defaults.ts
@@ -0,0 +1,13 @@
+export const DEFAULT_ROOT_CLASSNAME =
+ "w-full rounded-lg border border-zinc-200 dark:border-zinc-800";
+
+export const DEFAULT_TRIGGER_CLASSNAME =
+ "flex w-full items-center gap-2 px-4 py-3 text-sm font-medium transition-colors";
+
+export const DEFAULT_CONTENT_CLASSNAME =
+ "border-t border-zinc-200 px-4 pt-3 pb-3 dark:border-zinc-800";
+
+export const DEFAULT_CHEVRON_CLASSNAME =
+ "size-4 shrink-0 transition-transform duration-200 ease-out";
+
+export const DEFAULT_SPINNER_CLASSNAME = "size-4 shrink-0 animate-spin";
diff --git a/packages/core/src/primitives/tool-group/index.ts b/packages/core/src/primitives/tool-group/index.ts
new file mode 100644
index 0000000..0145775
--- /dev/null
+++ b/packages/core/src/primitives/tool-group/index.ts
@@ -0,0 +1 @@
+export { ToolGroup, type ToolGroupProps } from "./ToolGroup";