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 and tool-fallback
npx shadcn@latest add @assistant-ui/tool-group @assistant-ui/tool-fallbackThe @assistant-uinamespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/tool-group.json https://r.assistant-ui.com/base/tool-fallback.jsonMain Component
npm install @assistant-ui/react class-variance-authority tw-shimmer"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: "border-muted-foreground/30 bg-muted/30 rounded-lg border 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) => { 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 origin-left items-center gap-2 text-sm transition-[color,scale] active:scale-[0.98]", "group-data-[variant=ghost]/tool-group-root:text-muted-foreground group-data-[variant=ghost]/tool-group-root:hover:text-foreground group-data-[variant=ghost]/tool-group-root:py-1.5", "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-3 shrink-0 animate-spin [animation-duration:0.6s]" /> )} <span data-slot="tool-group-trigger-label" className={cn( "aui-tool-group-trigger-label-wrapper relative inline-block text-start leading-none font-medium", "group-data-[variant=ghost]/tool-group-root:font-normal", "group-data-[variant=outline]/tool-group-root:grow", "group-data-[variant=muted]/tool-group-root:grow", )} > <span className="text-xs">{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 text-xs motion-reduce:animate-none" > {label} </span> )} </span> <ChevronDownIcon data-slot="tool-group-trigger-chevron" className={cn( "aui-tool-group-trigger-chevron size-3 shrink-0", "transition-transform duration-(--animation-duration) ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none", "-rotate-90", "group-data-open/trigger:rotate-0", "group-data-panel-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-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none", "data-closed:animate-collapsible-up", "data-open:animate-collapsible-down", "data-closed:fill-mode-forwards", "data-closed:pointer-events-none", "data-open:duration-(--animation-duration)", "data-closed:duration-(--animation-duration)", className, )} {...props} > <div className={cn( "mt-2 flex flex-col gap-2", "group-data-[variant=ghost]/tool-group-root:mt-1 group-data-[variant=ghost]/tool-group-root:gap-1", "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", "[&>*]:animate-in [&>*]:fade-in-0 [&>*]:blur-in-[2px] [&>*]:slide-in-from-top-1 [&>*]:duration-(--animation-duration) [&>*]:ease-[cubic-bezier(0.32,0.72,0,1)]", "[&>*]:motion-reduce:animate-none", "[&>*:nth-child(2)]:[animation-delay:40ms]", "[&>*:nth-child(3)]:[animation-delay:80ms]", "[&>*:nth-child(4)]:[animation-delay:120ms]", "[&>*:nth-child(n+5)]:[animation-delay:160ms]", )} > {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> );};/** * @deprecated This wrapper targets the legacy `components.ToolGroup` prop * on `<MessagePrimitive.Parts>`. Use `<MessagePrimitive.GroupedParts>` with * a `groupBy` returning `"group-tool"` and compose `ToolGroupRoot` / * `ToolGroupTrigger` / `ToolGroupContent` directly. See `thread.tsx`. */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,};"use client";import { memo, useCallback, useRef, useState } from "react";import { AlertCircleIcon, CheckIcon, ChevronDownIcon, LoaderIcon, XCircleIcon,} from "lucide-react";import { useScrollLock, useToolCallElapsed, type ToolApprovalOption, type ToolCallMessagePart, type ToolCallMessagePartProps, type ToolCallMessagePartStatus, type ToolCallMessagePartComponent,} from "@assistant-ui/react";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";import { Button } from "@/components/ui/button";const ANIMATION_DURATION = 200;const pressable = "active:scale-[0.98]";export type ToolFallbackRootProps = Omit< React.ComponentProps<typeof Collapsible>, "open" | "onOpenChange"> & { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean;};function ToolFallbackRoot({ className, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children, ...props}: ToolFallbackRootProps) { 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) => { lockScroll(); if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, [lockScroll, isControlled, controlledOnOpenChange], ); return ( <Collapsible ref={collapsibleRef} data-slot="tool-fallback-root" open={isOpen} onOpenChange={handleOpenChange} className={cn( "aui-tool-fallback-root group/tool-fallback-root w-full", className, )} style={ { "--animation-duration": `${ANIMATION_DURATION}ms`, } as React.CSSProperties } {...props} > {children} </Collapsible> );}type ToolStatus = ToolCallMessagePartStatus["type"];const statusIconMap: Record<ToolStatus, React.ElementType> = { running: LoaderIcon, complete: CheckIcon, incomplete: XCircleIcon, "requires-action": AlertCircleIcon,};const formatToolDuration = (ms: number) => { if (ms < 1000) return "<1s"; const seconds = ms / 1000; if (seconds < 10) return `${(Math.floor(seconds * 10) / 10).toFixed(1)}s`; if (seconds < 60) return `${Math.floor(seconds)}s`; return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;};function ToolFallbackDuration({ className, ...props}: React.ComponentProps<"span">) { const elapsedMs = useToolCallElapsed(); if (elapsedMs === undefined) return null; return ( <span data-slot="tool-fallback-duration" className={cn( "aui-tool-fallback-duration text-muted-foreground text-xs tabular-nums", className, )} {...props} > {formatToolDuration(elapsedMs)} </span> );}function ToolFallbackTrigger({ toolName, status, className, ...props}: React.ComponentProps<typeof CollapsibleTrigger> & { toolName: string; status?: ToolCallMessagePartStatus;}) { const statusType = status?.type ?? "complete"; const isRunning = statusType === "running"; const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; const Icon = statusIconMap[statusType]; const label = isCancelled ? "Cancelled tool" : "Used tool"; return ( <CollapsibleTrigger data-slot="tool-fallback-trigger" className={cn( "aui-tool-fallback-trigger group/trigger text-muted-foreground hover:text-foreground flex w-fit origin-left items-center gap-2 py-1.5 text-sm transition-[color,scale] active:scale-[0.98]", className, )} {...props} > <Icon data-slot="tool-fallback-trigger-icon" className={cn( "aui-tool-fallback-trigger-icon size-4 shrink-0", isCancelled && "text-muted-foreground", isRunning && "animate-spin [animation-duration:0.6s]", )} /> <span data-slot="tool-fallback-trigger-label" className={cn( "aui-tool-fallback-trigger-label-wrapper relative inline-block text-start leading-none", isCancelled && "text-muted-foreground line-through", )} > <span> {label}: <b>{toolName}</b> </span> {isRunning && ( <span aria-hidden data-slot="tool-fallback-trigger-shimmer" className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" > {label}: <b>{toolName}</b> </span> )} </span> <ToolFallbackDuration /> <ChevronDownIcon data-slot="tool-fallback-trigger-chevron" className={cn( "aui-tool-fallback-trigger-chevron size-4 shrink-0", "transition-transform duration-(--animation-duration) ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none", "-rotate-90", "group-data-open/trigger:rotate-0", "group-data-panel-open/trigger:rotate-0", )} /> </CollapsibleTrigger> );}function ToolFallbackContent({ className, children, ...props}: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-slot="tool-fallback-content" className={cn( "aui-tool-fallback-content relative overflow-hidden text-sm outline-none", "group/collapsible-content ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none", "data-closed:animate-collapsible-up", "data-open:animate-collapsible-down", "data-closed:fill-mode-forwards", "data-closed:pointer-events-none", "data-open:duration-(--animation-duration)", "data-closed:duration-(--animation-duration)", className, )} {...props} > <div className={cn( "flex flex-col gap-2 ps-6 pt-1 pb-2 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none", "group-data-open/collapsible-content:animate-in group-data-open/collapsible-content:fade-in-0 group-data-open/collapsible-content:blur-in-[2px] group-data-open/collapsible-content:slide-in-from-top-1", "group-data-closed/collapsible-content:animate-out group-data-closed/collapsible-content:fade-out-0 group-data-closed/collapsible-content:blur-out-[2px] group-data-closed/collapsible-content:slide-out-to-top-1", "group-data-closed/collapsible-content:duration-(--animation-duration) group-data-open/collapsible-content:duration-(--animation-duration)", )} > {children} </div> </CollapsibleContent> );}function ToolFallbackArgs({ argsText, className, ...props}: React.ComponentProps<"div"> & { argsText?: string;}) { if (!argsText) return null; return ( <div data-slot="tool-fallback-args" className={cn("aui-tool-fallback-args", className)} {...props} > <pre className="aui-tool-fallback-args-value bg-muted/50 text-foreground/90 rounded-md p-2.5 text-xs whitespace-pre-wrap"> {argsText} </pre> </div> );}function ToolFallbackResult({ result, className, ...props}: React.ComponentProps<"div"> & { result?: unknown;}) { if (result === undefined) return null; return ( <div data-slot="tool-fallback-result" className={cn("aui-tool-fallback-result", className)} {...props} > <p className="aui-tool-fallback-result-header text-muted-foreground text-xs font-medium"> Result: </p> <pre className="aui-tool-fallback-result-content bg-muted/50 text-foreground/90 mt-1 rounded-md p-2.5 text-xs whitespace-pre-wrap"> {typeof result === "string" ? result : JSON.stringify(result, null, 2)} </pre> </div> );}function ToolFallbackError({ status, className, ...props}: React.ComponentProps<"div"> & { status?: ToolCallMessagePartStatus;}) { if (status?.type !== "incomplete") return null; const error = status.error; const errorText = error ? typeof error === "string" ? error : JSON.stringify(error) : null; if (!errorText) return null; const isCancelled = status.reason === "cancelled"; const headerText = isCancelled ? "Cancelled reason:" : "Error:"; return ( <div data-slot="tool-fallback-error" className={cn("aui-tool-fallback-error", className)} {...props} > <p className="aui-tool-fallback-error-header text-muted-foreground font-semibold"> {headerText} </p> <p className="aui-tool-fallback-error-reason text-muted-foreground"> {errorText} </p> </div> );}const APPROVED_RESULT = "Approved by user";const DENIED_RESULT = "User denied tool execution";const APPROVAL_OPTION_DEFAULT_LABELS: Record<string, string> = { "allow-once": "Allow", "allow-always": "Always allow", "reject-once": "Deny", "reject-always": "Always deny",};const isAllowKind = (kind: string) => kind === "allow-once" || kind === "allow-always";const approvalOptionLabel = (option: ToolApprovalOption) => option.label ?? (Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, option.kind) ? APPROVAL_OPTION_DEFAULT_LABELS[option.kind] : undefined) ?? option.id;function ToolFallbackApproval({ className, addResult, resume, interrupt, approval, respondToApproval, ...props}: React.ComponentProps<"div"> & Partial< Pick<ToolCallMessagePartProps, "addResult" | "resume" | "respondToApproval"> > & { interrupt?: ToolCallMessagePart["interrupt"]; approval?: ToolCallMessagePart["approval"]; }) { const [submitted, setSubmitted] = useState(false); const [confirmingId, setConfirmingId] = useState<string | null>(null); if ( approval != null && (approval.approved !== undefined || approval.resolution !== undefined) ) return null; // Custom (`_`-prefixed) kinds cannot be resolved to a boolean by the kit; // hosts using custom kinds render their own bar. A declared option list is // a host constraint: the kit never adds an approval path beyond it, but // always preserves a refusal path. const declaredOptions = respondToApproval ? approval?.options : undefined; const options = declaredOptions?.filter((o) => Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, o.kind), ); const respond = (approved: boolean) => { if (submitted) return; if ( approval != null && approval.approved === undefined && respondToApproval ) { respondToApproval({ approved }); } else if (interrupt) { resume?.({ approved }); } else { addResult?.(approved ? APPROVED_RESULT : DENIED_RESULT); } setSubmitted(true); }; const respondWithOption = (option: ToolApprovalOption) => { if (submitted) return; respondToApproval?.({ optionId: option.id }); setSubmitted(true); setConfirmingId(null); }; const handleOption = (option: ToolApprovalOption) => { if (option.confirm) { setConfirmingId(option.id); } else { respondWithOption(option); } }; const confirming = confirmingId != null ? options?.find((o) => o.id === confirmingId) : undefined; if (confirming) { const confirmMeta = typeof confirming.confirm === "object" ? confirming.confirm : undefined; const confirmDescription = confirmMeta?.description ?? confirming.description; return ( <div data-slot="tool-fallback-approval-confirm" className={cn( "aui-tool-fallback-approval-confirm flex flex-col gap-2 pt-1", className, )} {...props} > <p className="aui-tool-fallback-approval-confirm-title font-semibold"> {confirmMeta?.title ?? `${approvalOptionLabel(confirming)}?`} </p> {confirmDescription && ( <p className="aui-tool-fallback-approval-confirm-description text-muted-foreground"> {confirmDescription} </p> )} {confirming.grants && confirming.grants.length > 0 && ( <ul className="aui-tool-fallback-approval-confirm-grants flex flex-col gap-1"> {confirming.grants.map((grant) => ( <li key={grant}> <code className="aui-tool-fallback-approval-confirm-grant bg-muted rounded px-1.5 py-0.5 text-xs"> {grant} </code> </li> ))} </ul> )} <div className="flex items-center gap-2"> <Button size="sm" className={pressable} onClick={() => respondWithOption(confirming)} disabled={submitted} > Confirm </Button> <Button size="sm" variant="outline" className={pressable} onClick={() => setConfirmingId(null)} disabled={submitted} > Back </Button> </div> </div> ); } if (declaredOptions && declaredOptions.length > 0) { const allowOptions = options?.filter((o) => isAllowKind(o.kind)) ?? []; const rejectOptions = options?.filter((o) => !isAllowKind(o.kind)) ?? []; return ( <div data-slot="tool-fallback-approval" className={cn( "aui-tool-fallback-approval flex flex-wrap items-center gap-2 pt-1", className, )} {...props} > {[...allowOptions, ...rejectOptions].map((option) => ( <Button key={option.id} size="sm" variant={option === allowOptions[0] ? "default" : "outline"} className={pressable} onClick={() => handleOption(option)} disabled={submitted} > {approvalOptionLabel(option)} </Button> ))} {rejectOptions.length === 0 && ( <Button size="sm" variant="outline" className={pressable} onClick={() => respond(false)} disabled={submitted} > Deny </Button> )} </div> ); } return ( <div data-slot="tool-fallback-approval" className={cn( "aui-tool-fallback-approval flex items-center gap-2 pt-1", className, )} {...props} > <Button size="sm" className={pressable} onClick={() => respond(true)} disabled={submitted} > Allow </Button> <Button size="sm" variant="outline" className={pressable} onClick={() => respond(false)} disabled={submitted} > Deny </Button> </div> );}const ToolFallbackImpl: ToolCallMessagePartComponent = ({ toolName, argsText, result, status, addResult, resume, interrupt, approval, respondToApproval,}) => { const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; const isRequiresAction = status?.type === "requires-action"; const [open, setOpen] = useState(isRequiresAction); const [prevRequiresAction, setPrevRequiresAction] = useState(isRequiresAction); if (isRequiresAction !== prevRequiresAction) { setPrevRequiresAction(isRequiresAction); if (isRequiresAction) setOpen(true); } return ( <ToolFallbackRoot open={open} onOpenChange={setOpen}> <ToolFallbackTrigger toolName={toolName} status={status} /> <ToolFallbackContent> <ToolFallbackError status={status} /> <ToolFallbackArgs argsText={argsText} className={cn(isCancelled && "opacity-60")} /> {isRequiresAction && ( <ToolFallbackApproval addResult={addResult} resume={resume} interrupt={interrupt} approval={approval} respondToApproval={respondToApproval} /> )} {!isCancelled && <ToolFallbackResult result={result} />} </ToolFallbackContent> </ToolFallbackRoot> );};const ToolFallback = memo( ToolFallbackImpl,) as unknown as ToolCallMessagePartComponent & { Root: typeof ToolFallbackRoot; Trigger: typeof ToolFallbackTrigger; Content: typeof ToolFallbackContent; Args: typeof ToolFallbackArgs; Result: typeof ToolFallbackResult; Error: typeof ToolFallbackError; Approval: typeof ToolFallbackApproval;};ToolFallback.displayName = "ToolFallback";ToolFallback.Root = ToolFallbackRoot;ToolFallback.Trigger = ToolFallbackTrigger;ToolFallback.Content = ToolFallbackContent;ToolFallback.Args = ToolFallbackArgs;ToolFallback.Result = ToolFallbackResult;ToolFallback.Error = ToolFallbackError;ToolFallback.Approval = ToolFallbackApproval;export { ToolFallback, ToolFallbackRoot, ToolFallbackTrigger, ToolFallbackContent, ToolFallbackArgs, ToolFallbackResult, ToolFallbackError, ToolFallbackApproval,};shadcn/ui dependencies
npm install @base-ui/react"use client";import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;}function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { return ( <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} /> );}function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { return ( <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} /> );}export { Collapsible, CollapsibleTrigger, CollapsibleContent };import { Button as ButtonPrimitive } from "@base-ui/react/button";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const buttonVariants = cva( "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground aria-expanded:bg-secondary aria-expanded:text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", icon: "size-8", "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "icon-lg": "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, },);function Button({ className, variant = "default", size = "default", ...props}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { return ( <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> );}export { Button, buttonVariants };This adds /components/assistant-ui/tool-group.tsx and /components/assistant-ui/tool-fallback.tsx files to your project, which you can adjust as needed.
Use it in your application
Use MessagePrimitive.GroupedParts with groupPartByType to map tool-call parts to "group-tool". The group case wraps children; the tool-call leaf renders the resolved tool UI or your fallback.
import { MessagePrimitive, groupPartByType } from "@assistant-ui/react";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import {
ToolGroupContent,
ToolGroupRoot,
ToolGroupTrigger,
} from "@/components/assistant-ui/tool-group";
const AssistantMessage = () => {
return (
<MessagePrimitive.Root>
<MessagePrimitive.GroupedParts
groupBy={groupPartByType({
"tool-call": ["group-tool"],
})}
>
{({ part, children }) => {
switch (part.type) {
case "group-tool":
return (
<ToolGroupRoot>
<ToolGroupTrigger
count={part.indices.length}
active={part.status.type === "running"}
/>
<ToolGroupContent>{children}</ToolGroupContent>
</ToolGroupRoot>
);
case "tool-call":
return part.toolUI ?? <ToolFallback {...part} />;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
</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 |
|---|---|
outline | Rounded border (default) |
ghost | No additional styling |
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: "outline" | "ghost" | "muted"= "outline"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
ToolGroupTriggerPropscount: numberNumber of tool calls to display in the label.
active: boolean= falseShows loading spinner and shimmer animation when true.
ToolGroup (Legacy Wrapper)
ToolGroup is kept for existing code that still uses the deprecated components.ToolGroup prop on MessagePrimitive.Parts. Prefer composing ToolGroupRoot, ToolGroupTrigger, and ToolGroupContent inside MessagePrimitive.GroupedParts.
ToolGroupPropsstartIndex: numberThe index of the first tool call in the group.
endIndex: numberThe index of the last tool call in the group.
children: ReactNodeThe rendered tool call components.
Related Components
- ToolFallback - Default UI for tools without custom renderers
- PartGrouping - Advanced message part grouping (experimental)