32 / 122 · Tool use
Parallel tools
Calls that went out together, collapsed to one row until you want the detail.
Installation
npx shadcn@latest add "@assistant-ui/elements-tool-group"
Usage
import { ToolGroup } from "@/components/elements/tool-group";
<ToolGroup
label="Read 4 files in parallel"
tools={tools}
open={open}
onOpenChange={setOpen}
/>Props
ToolGroup
label*stringWhat the group did, as one line.
tools*GroupedTool[]Calls issued together. The header summarizes them: progress while any run, failures if any failed, otherwise the count.
open*booleanWhether the per-call list is expanded.
onOpenChange(open: boolean) => voidCalled when the group is expanded or collapsed.
classNamestringExtra classes merged onto the root.
GroupedTool
id*stringStable identity for the row key.
name*stringTool name, rendered in mono.
target*stringWhat the call acted on: a path, a query, an id.
state*"running" | "done" | "failed"Per-call state, rolled up into the header summary.
durationMsnumberPrinted once the call settles. Omit while running.
Source
tool-group.tsx"use client";
import { CheckIcon, ChevronRightIcon, Loader2Icon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { mono, paper } from "./surfaces";
export type GroupedToolState = "running" | "done" | "failed";
export interface GroupedTool {
id: string;
name: string;
target: string;
state: GroupedToolState;
durationMs?: number;
}
export function ToolGroup({
label,
tools,
open,
onOpenChange,
className,
}: {
label: string;
tools: readonly GroupedTool[];
open: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}) {
const running = tools.filter((tool) => tool.state === "running").length;
const failed = tools.filter((tool) => tool.state === "failed").length;
return (
<div
className={cn(
paper,
"flex w-full max-w-sm flex-col overflow-hidden rounded-2xl",
className,
)}
>
<button
type="button"
aria-expanded={open}
onClick={() => onOpenChange?.(!open)}
className="hover:bg-foreground/[0.03] flex items-center gap-2.5 px-3.5 py-2.5 text-start transition-colors"
>
<ChevronRightIcon
className={cn(
"text-foreground/25 size-3 shrink-0 transition-transform duration-200 motion-reduce:transition-none",
open && "rotate-90",
)}
/>
<span className="min-w-0 flex-1 truncate text-[13.5px]">{label}</span>
<span className={cn(mono, "text-foreground/30 shrink-0 tabular-nums")}>
{running > 0
? `${tools.length - running}/${tools.length}`
: failed > 0
? `${failed} failed`
: `${tools.length} done`}
</span>
{running > 0 ? (
<Loader2Icon className="text-foreground/35 size-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
) : failed > 0 ? (
<XIcon className="size-3.5 shrink-0 text-red-500" />
) : (
<CheckIcon className="size-3.5 shrink-0 text-emerald-500" />
)}
</button>
{open && (
<div className="border-foreground/[0.06] fade-in slide-in-from-top-1 animate-in flex flex-col border-t duration-200">
{tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2.5 px-3.5 py-2"
>
<span className="flex size-3.5 shrink-0 items-center justify-center">
{tool.state === "running" ? (
<Loader2Icon className="text-foreground/35 size-3 animate-spin motion-reduce:animate-none" />
) : tool.state === "failed" ? (
<XIcon className="size-3 text-red-500" />
) : (
<CheckIcon className="size-3 text-emerald-500" />
)}
</span>
<span className={cn(mono, "text-foreground/55 shrink-0")}>
{tool.name}
</span>
<span className="text-foreground/80 min-w-0 flex-1 truncate text-[13px]">
{tool.target}
</span>
{tool.durationMs !== undefined && (
<span
className={cn(
mono,
"text-foreground/25 shrink-0 tabular-nums",
)}
>
{tool.durationMs}ms
</span>
)}
</div>
))}
</div>
)}
</div>
);
}