Wrapper for consecutive tool calls with collapsible and styled options.
A wrapper component that groups consecutive tool calls together, displaying them in a collapsible container with auto-expand behavior during streaming.
Getting Started
Add tool-group
npx shadcn@latest add https://r.assistant-ui.com/tool-group.jsonMain Component
npm install @assistant-ui/react class-variance-authority"use client";import { memo, useCallback, useRef, useState, type FC, type PropsWithChildren,} from "react";import { ChevronDownIcon, LoaderIcon } from "lucide-react";import { cva, type VariantProps } from "class-variance-authority";import { useScrollLock } from "@assistant-ui/react";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";const ANIMATION_DURATION = 200;const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", { variants: { variant: { outline: "rounded-lg border py-3", ghost: "", muted: "rounded-lg border border-muted-foreground/30 bg-muted/30 py-3", }, }, defaultVariants: { variant: "outline" },});export type ToolGroupRootProps = Omit< React.ComponentProps<typeof Collapsible>, "open" | "onOpenChange"> & VariantProps<typeof toolGroupVariants> & { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; };function ToolGroupRoot({ className, variant, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children, ...props}: ToolGroupRootProps) { const collapsibleRef = useRef<HTMLDivElement>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { if (!open) { lockScroll(); } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, [lockScroll, isControlled, controlledOnOpenChange], ); return ( <Collapsible ref={collapsibleRef} data-slot="tool-group-root" data-variant={variant ?? "outline"} open={isOpen} onOpenChange={handleOpenChange} className={cn( toolGroupVariants({ variant }), "group/tool-group-root", className, )} style={ { "--animation-duration": `${ANIMATION_DURATION}ms`, } as React.CSSProperties } {...props} > {children} </Collapsible> );}function ToolGroupTrigger({ count, active = false, className, ...props}: React.ComponentProps<typeof CollapsibleTrigger> & { count: number; active?: boolean;}) { const label = `${count} tool ${count === 1 ? "call" : "calls"}`; return ( <CollapsibleTrigger data-slot="tool-group-trigger" className={cn( "aui-tool-group-trigger group/trigger flex items-center gap-2 text-sm transition-colors", "group-data-[variant=outline]/tool-group-root:w-full group-data-[variant=outline]/tool-group-root:px-4", "group-data-[variant=muted]/tool-group-root:w-full group-data-[variant=muted]/tool-group-root:px-4", className, )} {...props} > {active && ( <LoaderIcon data-slot="tool-group-trigger-loader" className="aui-tool-group-trigger-loader size-4 shrink-0 animate-spin" /> )} <span data-slot="tool-group-trigger-label" className={cn( "aui-tool-group-trigger-label-wrapper relative inline-block text-left font-medium leading-none", "group-data-[variant=outline]/tool-group-root:grow", "group-data-[variant=muted]/tool-group-root:grow", )} > <span>{label}</span> {active && ( <span aria-hidden data-slot="tool-group-trigger-shimmer" className="aui-tool-group-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" > {label} </span> )} </span> <ChevronDownIcon data-slot="tool-group-trigger-chevron" className={cn( "aui-tool-group-trigger-chevron size-4 shrink-0", "transition-transform duration-(--animation-duration) ease-out", "group-data-[state=closed]/trigger:-rotate-90", "group-data-[state=open]/trigger:rotate-0", )} /> </CollapsibleTrigger> );}function ToolGroupContent({ className, children, ...props}: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-slot="tool-group-content" className={cn( "aui-tool-group-content relative overflow-hidden text-sm outline-none", "group/collapsible-content ease-out", "data-[state=closed]:animate-collapsible-up", "data-[state=open]:animate-collapsible-down", "data-[state=closed]:fill-mode-forwards", "data-[state=closed]:pointer-events-none", "data-[state=open]:duration-(--animation-duration)", "data-[state=closed]:duration-(--animation-duration)", className, )} {...props} > <div className={cn( "mt-2 flex flex-col gap-2", "group-data-[variant=outline]/tool-group-root:mt-3 group-data-[variant=outline]/tool-group-root:border-t group-data-[variant=outline]/tool-group-root:px-4 group-data-[variant=outline]/tool-group-root:pt-3", "group-data-[variant=muted]/tool-group-root:mt-3 group-data-[variant=muted]/tool-group-root:border-t group-data-[variant=muted]/tool-group-root:px-4 group-data-[variant=muted]/tool-group-root:pt-3", )} > {children} </div> </CollapsibleContent> );}type ToolGroupComponent = FC< PropsWithChildren<{ startIndex: number; endIndex: number }>> & { Root: typeof ToolGroupRoot; Trigger: typeof ToolGroupTrigger; Content: typeof ToolGroupContent;};const ToolGroupImpl: FC< PropsWithChildren<{ startIndex: number; endIndex: number }>> = ({ children, startIndex, endIndex }) => { const toolCount = endIndex - startIndex + 1; return ( <ToolGroupRoot> <ToolGroupTrigger count={toolCount} /> <ToolGroupContent>{children}</ToolGroupContent> </ToolGroupRoot> );};const ToolGroup = memo(ToolGroupImpl) as unknown as ToolGroupComponent;ToolGroup.displayName = "ToolGroup";ToolGroup.Root = ToolGroupRoot;ToolGroup.Trigger = ToolGroupTrigger;ToolGroup.Content = ToolGroupContent;export { ToolGroup, ToolGroupRoot, ToolGroupTrigger, ToolGroupContent, toolGroupVariants,};This adds a /components/assistant-ui/tool-group.tsx file to your project, which you can adjust as needed.
Use it in your application
Pass the ToolGroup component to the MessagePrimitive.Parts component
import { ToolGroup } from "@/components/assistant-ui/tool-group";
const AssistantMessage = () => {
return (
<MessagePrimitive.Root>
<MessagePrimitive.Parts
components={{
ToolGroup,
}}
/>
</MessagePrimitive.Root>
);
};Variants
Use the variant prop on ToolGroup.Root to change the visual style:
<ToolGroup.Root variant="outline">...</ToolGroup.Root>
<ToolGroup.Root variant="muted">...</ToolGroup.Root>| Variant | Description |
|---|---|
default | No additional styling |
outline | Rounded border |
muted | Muted background with border |
Examples
Streaming Demo (Custom UI + Fallback)
Interactive demo showing tool group with custom tool UIs and ToolFallback working together. Watch as weather cards stream in with loading states, followed by a search tool using the fallback UI.
Custom Tool UIs
ToolGroup works with any custom tool UI components:
// Custom Weather Tool UI
function WeatherToolUI({ location, temperature, condition }) {
return (
<div className="flex items-center gap-3 rounded-lg border p-3">
<WeatherIcon condition={condition} />
<div>
<div className="text-xs text-muted-foreground">{location}</div>
<div className="text-lg font-medium">{temperature}°F</div>
</div>
</div>
);
}
// Use in ToolGroup
<ToolGroupRoot variant="outline">
<ToolGroupTrigger count={3} />
<ToolGroupContent>
<WeatherToolUI location="New York" temperature={65} condition="Cloudy" />
<WeatherToolUI location="London" temperature={55} condition="Rainy" />
<SearchToolUI query="best restaurants" results={24} />
</ToolGroupContent>
</ToolGroupRoot>Composable API
All sub-components are exported for custom layouts:
| Component | Description |
|---|---|
ToolGroup.Root | Collapsible container with scroll lock and variants |
ToolGroup.Trigger | Header with tool count, shimmer animation, and chevron |
ToolGroup.Content | Animated collapsible content wrapper |
import {
ToolGroup,
ToolGroupRoot,
ToolGroupTrigger,
ToolGroupContent,
} from "@/components/assistant-ui/tool-group";
// Compound component syntax
<ToolGroup.Root variant="outline" defaultOpen>
<ToolGroup.Trigger count={3} active={false} />
<ToolGroup.Content>
{/* Any tool UI components - custom or ToolFallback */}
</ToolGroup.Content>
</ToolGroup.Root>API Reference
ToolGroupRoot
ToolGroupRootPropsvariant: "default" | "outline" | "muted"= "default"Visual variant of the tool group container.
open?: booleanControlled open state.
onOpenChange?: (open: boolean) => voidCallback when open state changes.
defaultOpen: boolean= falseInitial open state for uncontrolled usage.
ToolGroupTrigger
ToolGroupTriggerPropscountrequired: numberNumber of tool calls to display in the label.
active: boolean= falseShows loading spinner and shimmer animation when true.
ToolGroup (Default Export)
ToolGroupPropsstartIndexrequired: numberThe index of the first tool call in the group.
endIndexrequired: numberThe index of the last tool call in the group.
childrenrequired: ReactNodeThe rendered tool call components.
Related Components
- ToolFallback - Default UI for tools without custom renderers
- PartGrouping - Advanced message part grouping (experimental)