Default UI component for tools without dedicated custom renderers.
Getting Started
Add tool-fallback
npx shadcn@latest add @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-fallback.jsonMain Component
npm install @assistant-ui/react tw-shimmer"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/reactimport { 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 };"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 };This adds a /components/assistant-ui/tool-fallback.tsx file to your project, which you can adjust as needed.
Use it in your application
Pass the ToolFallback component to the MessagePrimitive.Parts component
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
const AssistantMessage = () => {
return (
<MessagePrimitive.Root>
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "tool-call") return <ToolFallback {...part} />;
return null;
}}
</MessagePrimitive.Parts>
</MessagePrimitive.Root>
);
};Examples
Streaming Demo
Interactive demo showing the full tool call lifecycle: running → complete.
Running State
Shows a loading spinner and shimmer animation while the tool is executing.
Cancelled State
Shows a muted appearance when a tool call was cancelled.
Approval State
Shows the default Allow / Deny buttons rendered when a tool call enters requires-action. A tool call whose approval gate is already decided renders no buttons. The block auto-expands so the decision is visible without a click. Edit your project's copy of tool-fallback.tsx to add trust escalation, edit-args, custom labels, or analytics hooks — the shadcn philosophy is you own the file.
Composable API
All sub-components are exported for custom layouts:
| Component | Description |
|---|---|
ToolFallback.Root | Collapsible container with scroll lock |
ToolFallback.Trigger | Header with tool name, status icon, and shimmer |
ToolFallback.Content | Animated collapsible content wrapper |
ToolFallback.Args | Displays tool arguments |
ToolFallback.Result | Displays tool execution result |
ToolFallback.Error | Displays error or cancellation messages |
ToolFallback.Approval | Renders Allow / Deny buttons for requires-action tools until a decision is recorded; wires resume / addResult / respondToApproval |
import {
ToolFallback,
ToolFallbackRoot,
ToolFallbackTrigger,
ToolFallbackContent,
ToolFallbackArgs,
ToolFallbackResult,
ToolFallbackError,
ToolFallbackApproval,
} from "@/components/assistant-ui/tool-fallback";
// Compound component syntax
<ToolFallback.Root>
<ToolFallback.Trigger toolName="get_weather" status={status} />
<ToolFallback.Content>
<ToolFallback.Error status={status} />
<ToolFallback.Args argsText={argsText} />
<ToolFallback.Approval
addResult={addResult}
resume={resume}
interrupt={interrupt}
approval={approval}
respondToApproval={respondToApproval}
/>
<ToolFallback.Result result={result} />
</ToolFallback.Content>
</ToolFallback.Root>Related Components
- ToolGroup - Group consecutive tool calls together