Installation
Complete project setup with react/aria, then install these components:
pnpm exec frontfriend download trace code-block icon button message bubble item sheet -f react --primitive aria Map input streaming to running, client input waiting to pending, output errors to error, and available output to a complete line. A pending approval uses Item outside Trace. Keep the approval ID with the response and accept only one decision. These examples do not execute commands.
The output sheet uses JSON syntax highlighting through the Code Block helpers. Plain command output does not load the tokenizer.
Usage
Approval
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from "@/components/ui/item";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Trace, TraceLine, TraceIcon, TraceTitle } from "@/components/ui/trace";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
export default function AiToolExample() {
const [decision, setDecision] = React.useState<
"pending" | "approved" | "denied"
>("pending");
const result = React.useRef<HTMLParagraphElement>(null);
React.useEffect(() => {
if (decision !== "pending") result.current?.focus();
}, [decision]);
return (
<div style={frame}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>
Run the build before I ship this change.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
{decision === "pending" ? (
<Item variant="outline" size="sm">
<ItemMedia variant="icon">
<Icon name="Terminal" />
</ItemMedia>
<ItemContent>
<ItemTitle>Run pnpm build?</ItemTitle>
<ItemDescription>The command runs in this project.</ItemDescription>
</ItemContent>
<ItemActions>
<Button
size="sm"
variant="outline"
onPress={() => setDecision("denied")}
>
Deny
</Button>
<Button size="sm" onPress={() => setDecision("approved")}>
Approve
</Button>
</ItemActions>
</Item>
) : (
<Message>
<MessageContent style={{ gap: 16 }}>
<Trace>
<TraceLine
status={decision === "approved" ? "running" : undefined}
>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{decision === "approved"
? "Running pnpm build"
: "Denied pnpm build"}
</TraceTitle>
</TraceLine>
</Trace>
<p ref={result} tabIndex={-1}>
{decision === "approved"
? "Build approved. The command is running."
: "Build cancelled. No command was run."}
</p>
</MessageContent>
</Message>
)}
<div>
<Button
size="sm"
variant="ghost"
onPress={() => setDecision("pending")}
>
Reset
</Button>
</div>
</div>
);
} Output sheet
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Trace, TraceLine, TraceIcon, TraceTitle } from "@/components/ui/trace";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { useHighlightedCode } from "@/components/ui/code-block/use-highlighted-code";
import {
CodeBlock,
CodeBlockContent,
CodeBlockLine,
} from "@/components/ui/code-block";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
function JsonOutput({ value }: { value: string }) {
const lines = useHighlightedCode(value, "json");
return (
<CodeBlock size="sm">
<CodeBlockContent lineNumbers wrap>
{lines.map((line, index) => (
<CodeBlockLine key={index}>{line}</CodeBlockLine>
))}
</CodeBlockContent>
</CodeBlock>
);
}
export default function AiToolExample() {
const value =
'{\n "name": "checkout",\n "private": true,\n "scripts": {\n "build": "vite build",\n "test": "vitest run"\n }\n}';
return (
<div style={frame}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Which commands does this app provide?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16 }}>
<Trace>
<Sheet>
<Button variant="ghost">
<TraceLine>
<TraceIcon>
<Icon name="FileText" />
</TraceIcon>
<TraceTitle>Read package.json</TraceTitle>
<TraceIcon>
<Icon name="ChevronRight" />
</TraceIcon>
</TraceLine>
</Button>
<SheetContent>
<SheetHeader>
<SheetTitle>package.json</SheetTitle>
<SheetDescription>
Build and test commands for this project.
</SheetDescription>
</SheetHeader>
<div style={{ minWidth: 0, paddingBlock: 24 }}>
<JsonOutput value={value} />
</div>
</SheetContent>
</Sheet>
</Trace>
<p>The app provides build and test commands.</p>
</MessageContent>
</Message>
</div>
);
} Complete example
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Button } from "@/components/ui/button";
import * as React from "react";
type Phase = "complete" | "running" | "error" | "pending";
function useRun(initial: Phase) {
const [phase, setPhase] = React.useState(initial);
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
React.useEffect(() => {
clearTimeout(timer.current);
setPhase(initial);
}, [initial]);
React.useEffect(() => () => clearTimeout(timer.current), []);
function replay() {
clearTimeout(timer.current);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setPhase("complete");
return;
}
setPhase("running");
timer.current = setTimeout(() => setPhase("complete"), 3600);
}
return { phase, replay };
}
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
const commands = [
{ command: "node --version", output: "v24.18.0" },
{ command: "npm --version", output: "11.6.2" },
{
command: "pnpm --version",
output: "command not found: pnpm",
failed: true,
},
];
export default function AiToolExample() {
const { phase, replay } = useRun("complete");
return (
<div style={frame}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Which package managers are installed?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16, minWidth: 0 }}>
<Trace>
{commands.map((command, index) =>
phase === "complete" || (index === 0 && phase !== "pending") ? (
<TraceCollapsible
key={command.command}
defaultExpanded={command.failed}
>
<TraceCollapsibleTrigger>
<TraceLine status={command.failed ? "error" : undefined}>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{`${command.failed ? "Failed to run" : "Ran"} ${command.command}`}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{command.output}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
) : (
<TraceLine
key={command.command}
status={
index === 2 && phase === "running" ? "pending" : phase
}
>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{`${
phase === "pending" ||
(index === 2 && phase === "running")
? "Waiting to run"
: phase === "error"
? "Failed to run"
: "Running"
} ${command.command}`}
</TraceTitle>
</TraceLine>
),
)}
</Trace>
{phase === "complete" && (
<p>Node 24 and npm 11 are installed. pnpm is not.</p>
)}
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
onPress={replay}
isDisabled={phase === "running"}
>
Replay
</Button>
</div>
</div>
);
} AI SDK
The adapter reads tool-runCommand parts and maps every input, approval, output, error, and denial state. Pass clientCallIds for client tools that remain input-available after streaming stops; they stay pending until addToolOutput supplies a result. Approval requests are Item cards outside Trace. Pass addToolApprovalResponse from your chat client. The adapter sends the approval ID once and permits retry if the callback rejects. Configure lastAssistantMessageIsCompleteWithApprovalResponses on your chat client to resume after all approvals are answered.
The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:
pnpm add ai
Pass a message from your chat client to the adapter. The supplied message does not call a provider.
import type { UIMessage } from "ai";
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from "@/components/ui/item";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import * as React from "react";
type SdkCommandMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: {
command: string;
};
output: {
stdout: string;
exitCode: number;
};
};
}
>;
const sdkInitialMessage: SdkCommandMessage = {
id: "tools",
role: "assistant",
parts: [
{
type: "tool-runCommand",
toolCallId: "version",
state: "output-available",
input: { command: "node --version" },
output: { stdout: "v24.18.0", exitCode: 0 },
},
{
type: "tool-runCommand",
toolCallId: "missing",
state: "output-error",
input: { command: "pnpm --version" },
errorText: "command not found: pnpm",
},
{
type: "tool-runCommand",
toolCallId: "streaming-input",
state: "input-streaming",
input: { command: "git status" },
},
{
type: "tool-runCommand",
toolCallId: "client",
state: "input-available",
input: { command: "read clipboard" },
},
{
type: "tool-runCommand",
toolCallId: "denied",
state: "output-denied",
input: { command: "rm -rf .cache" },
approval: { id: "deny-cache", approved: false },
},
{
type: "tool-runCommand",
toolCallId: "approve",
state: "approval-requested",
input: { command: "pnpm build" },
approval: { id: "build-approval" },
},
],
};
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
type SdkCommandPart = Extract<
SdkCommandMessage["parts"][number],
{
type: "tool-runCommand";
}
>;
function sdkCommandState(
part: SdkCommandPart,
streaming: boolean,
client = false,
) {
const command = part.input?.command ?? "command";
const denied =
part.state === "output-denied" ||
(part.state === "approval-responded" && !part.approval.approved);
const finished = part.state === "output-available" && !part.preliminary;
const waiting =
part.state === "approval-requested" ||
(part.state === "input-available" && client);
const approved =
part.state === "approval-responded" && part.approval.approved;
const failed =
part.state === "output-error" ||
(finished && part.output.exitCode !== 0) ||
(!denied && !finished && !waiting && !approved && !streaming);
const status = denied
? undefined
: waiting
? "pending"
: failed
? "error"
: finished
? undefined
: "running";
const title = `${denied ? "Denied" : waiting ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`;
const output =
part.state === "output-error"
? part.errorText
: part.state === "output-available"
? part.output.stdout
: undefined;
return { status, title, output } as const;
}
function SdkPanel({
title,
status,
children,
initialOpen = false,
}: {
title: string;
status?: "running" | "pending" | "error";
children: React.ReactNode;
initialOpen?: boolean;
}) {
const [open, setOpen] = React.useState<boolean>();
const expanded = open ?? initialOpen;
return (
<TraceCollapsible isExpanded={expanded} onExpandedChange={setOpen}>
<TraceCollapsibleTrigger>
<TraceLine status={status}>
<TraceIcon>
<Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
</TraceIcon>
<TraceTitle>{title}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>{children}</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function SdkCommandRow({
part,
streaming,
client = false,
}: {
part: SdkCommandPart;
streaming: boolean;
client?: boolean;
}) {
const view = sdkCommandState(part, streaming, client);
const output = view.output;
if (output !== undefined)
return (
<SdkPanel
title={view.title}
status={view.status}
initialOpen={view.status === "error" || view.status === "running"}
>
<CodeBlock
size="sm"
variant={view.status === "error" ? "destructive" : "default"}
>
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{output}
</CodeBlockContent>
</CodeBlock>
</SdkPanel>
);
return (
<TraceLine status={view.status}>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>{view.title}</TraceTitle>
</TraceLine>
);
}
function ToolSdkAdapter({
message,
streaming,
clientCallIds = [],
addToolApprovalResponse,
}: {
message: SdkCommandMessage;
streaming: boolean;
clientCallIds?: string[];
addToolApprovalResponse: (response: {
id: string;
approved: boolean;
}) => void | PromiseLike<void>;
}) {
const [submitted, setSubmitted] = React.useState<Record<string, boolean>>({});
const [errors, setErrors] = React.useState<Record<string, string>>({});
const pending = React.useRef(new Set<string>());
const results = React.useRef(new Map<string, HTMLDivElement>());
const focusApproval = React.useRef<string | undefined>(undefined);
React.useEffect(() => {
if (!focusApproval.current) return;
const responded = message.parts.find(
(part) =>
part.type === "tool-runCommand" &&
part.state !== "approval-requested" &&
"approval" in part &&
part.approval?.id === focusApproval.current,
);
if (responded) {
if (responded.type === "tool-runCommand")
results.current.get(responded.toolCallId)?.focus();
focusApproval.current = undefined;
}
}, [message]);
async function approve(id: string, approved: boolean) {
if (pending.current.has(id)) return;
pending.current.add(id);
focusApproval.current = id;
setSubmitted((current) => ({ ...current, [id]: true }));
setErrors((current) => ({ ...current, [id]: "" }));
try {
await addToolApprovalResponse({ id, approved });
} catch (error) {
pending.current.delete(id);
focusApproval.current = undefined;
setSubmitted((current) => ({ ...current, [id]: false }));
setErrors((current) => ({
...current,
[id]: error instanceof Error ? error.message : String(error),
}));
}
}
const parts = message.parts.filter(
(part): part is SdkCommandPart => part.type === "tool-runCommand",
);
return (
<div style={{ display: "grid", gap: 16 }}>
{parts
.filter((part) => part.state === "approval-requested")
.map((part) => (
<Item key={part.toolCallId} variant="outline" size="sm">
<ItemMedia variant="icon">
<Icon name="Terminal" />
</ItemMedia>
<ItemContent>
<ItemTitle>Run {part.input.command}?</ItemTitle>
{errors[part.approval.id] && (
<ItemDescription role="alert">
{errors[part.approval.id]}
</ItemDescription>
)}
</ItemContent>
<ItemActions>
<Button
size="sm"
variant="outline"
isDisabled={submitted[part.approval.id]}
onPress={() => void approve(part.approval.id, false)}
>
Deny
</Button>
<Button
size="sm"
isDisabled={submitted[part.approval.id]}
onPress={() => void approve(part.approval.id, true)}
>
Approve
</Button>
</ItemActions>
</Item>
))}
<Trace>
{parts
.filter((part) => part.state !== "approval-requested")
.map((part) => (
<div
key={part.toolCallId}
tabIndex={-1}
role="group"
aria-label={
sdkCommandState(
part,
streaming,
clientCallIds.includes(part.toolCallId),
).title
}
ref={(element) => {
if (element) results.current.set(part.toolCallId, element);
else results.current.delete(part.toolCallId);
}}
>
<SdkCommandRow
part={part}
streaming={streaming}
client={clientCallIds.includes(part.toolCallId)}
/>
</div>
))}
</Trace>
</div>
);
}
export default function AiToolExample() {
const [message, setMessage] = React.useState(sdkInitialMessage);
return (
<div style={frame}>
<ToolSdkAdapter
message={message}
streaming
clientCallIds={["client"]}
addToolApprovalResponse={({ id, approved }) =>
setMessage((current) => ({
...current,
parts: current.parts.map((part) =>
part.type === "tool-runCommand" &&
part.state === "approval-requested" &&
part.approval.id === id
? {
...part,
state: "approval-responded",
approval: { id, approved },
}
: part,
),
}))
}
/>
</div>
);
} States
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
function ToolStatesExample() {
return (
<Trace>
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Ran git status --short</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent
wrap
style={{
maxHeight: 256,
}}
>
{` M app/api/chat/route.ts
M components/chat.tsx`}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine status="error">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Failed to run pnpm dev</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent
wrap
style={{
maxHeight: 256,
}}
>
Command timed out after 120 seconds.
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceLine>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Denied rm -rf dist</TraceTitle>
</TraceLine>
<TraceLine status="running">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>Running pnpm test</TraceTitle>
</TraceLine>
</Trace>
);
}
export default function AiToolExample() {
return (
<div style={frame}>
<ToolStatesExample />
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | React Aria API |
code-block | React Aria API |
icon | React Aria API |
button | React Aria API |
message | React Aria API |
bubble | React Aria API |
item | React Aria API |
sheet | React Aria API |