Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download trace code-block badge icon button message bubble -f vue --primitive reka Keep preliminary output in the running state until the subagent returns its final result. Nested steps use stable IDs and controlled disclosures. The task and model badges describe the run, and the duration appears after its title.
Usage
Complete example
Save as SubagentExample.vue.
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from "vue";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
TraceDescription,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Badge } from "@/components/ui/badge";
type Phase = "complete" | "running" | "error" | "pending";
type AgentStep = {
id: string;
title: string;
status?: "running" | "error" | "pending";
output?: string;
children?: AgentStep[];
report?: string;
task?: string;
duration?: string;
model?: string;
tool?: string;
};
const props = withDefaults(
defineProps<{
state?: Phase;
nested?: boolean;
steps?: AgentStep[];
open?: Record<string, boolean>;
}>(),
{ state: "complete", nested: false, open: () => ({}) },
);
const emit = defineEmits<{ expand: [id: string, value: boolean] }>();
const phase = ref<Phase>(props.state);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
() => props.state,
(value) => {
clearTimeout(timer);
phase.value = value;
},
);
onBeforeUnmount(() => clearTimeout(timer));
function replay() {
clearTimeout(timer);
expanded.value = {};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
phase.value = "complete";
return;
}
phase.value = "running";
timer = setTimeout(() => {
phase.value = "complete";
}, 3600);
}
const expanded = ref<Record<string, boolean>>({});
function expand(id: string, value: boolean) {
expanded.value = { ...expanded.value, [id]: value };
}
const steps = computed<AgentStep[]>(() => [
{
id: "research",
title:
phase.value === "complete"
? "Ran the research subagent"
: phase.value === "running"
? "Running the research subagent"
: phase.value === "pending"
? "Research subagent queued"
: "Research subagent failed",
status: phase.value === "complete" ? undefined : phase.value,
task: "Find out why image optimization reruns on every deploy.",
duration: phase.value === "complete" ? "48s" : undefined,
model: "Research model",
tool: "runCommand",
children: [
{
id: "find",
title:
phase.value === "pending"
? "Run rg cacheKey scripts"
: "Ran rg cacheKey scripts",
status: phase.value === "pending" ? "pending" : undefined,
output:
phase.value === "pending"
? undefined
: "scripts/optimize-images.ts:14: const cacheKey = process.env.COMMIT_SHA",
},
{
id: "read",
title:
phase.value === "complete"
? "Read scripts/optimize-images.ts"
: phase.value === "error"
? "Failed to read scripts/optimize-images.ts"
: phase.value === "pending"
? "Read scripts/optimize-images.ts"
: "Reading scripts/optimize-images.ts",
status: phase.value === "complete" ? undefined : phase.value,
output:
phase.value === "complete"
? 'const cacheDir = ".nuxt/cache/images"\nconst cacheKey = process.env.COMMIT_SHA\nconst cached = await restoreCache(cacheDir, cacheKey)'
: phase.value === "error"
? "The file could not be read."
: undefined,
},
{
id: "count",
title:
phase.value === "complete"
? "Counted public/images"
: "Count public/images",
status: phase.value === "complete" ? undefined : "pending",
output: phase.value === "complete" ? "1240" : undefined,
},
],
report:
phase.value === "complete"
? "The cache is keyed on the commit SHA, so each deployment optimizes all 1,240 images again."
: undefined,
},
]);
function icon(step: AgentStep) {
return step.status === "running"
? "LoaderCircle"
: step.status === "error"
? "CircleAlert"
: step.status === "pending"
? "CircleDashed"
: "CircleCheck";
}
</script>
<template>
<Trace v-if="nested">
<template v-for="step in props.steps" :key="step.id">
<TraceCollapsible
:unmount-on-hide="false"
v-if="step.children || step.output"
:open="open[step.id] ?? Boolean(step.children)"
@update:open="emit('expand', step.id, $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="step.status">
<TraceIcon>
<Icon :name="icon(step)" />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
<TraceDescription v-if="step.duration">
{{ step.duration }}
</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div style="display: grid; gap: 12px">
<template v-if="step.task">
<div style="display: flex; flex-wrap: wrap; gap: 6px">
<Badge variant="secondary">{{ step.model }}</Badge>
<Badge variant="outline">{{ step.tool }}</Badge>
</div>
<p>{{ step.task }}</p>
</template>
<SubagentExample
v-if="step.children"
:steps="step.children"
nested
:open="open"
@expand="(id, value) => emit('expand', id, value)"
/>
<CodeBlock
v-if="step.output"
size="sm"
:variant="step.status === 'error' ? 'destructive' : 'default'"
>
<CodeBlockContent wrap style="max-height: 256px">
{{ step.output }}
</CodeBlockContent>
</CodeBlock>
<p v-if="step.report">{{ step.report }}</p>
</div>
</TraceCollapsibleContent>
</TraceCollapsible>
<template v-else>
<TraceLine :status="step.status">
<TraceIcon>
<Icon :name="icon(step)" />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
<TraceDescription v-if="step.duration">
{{ step.duration }}
</TraceDescription>
</TraceLine>
</template>
</template>
</Trace>
<div
v-else
style="
width: 100%;
max-width: 384px;
min-width: 0;
min-height: 288px;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>
Why does image optimization rerun on every deploy?
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style="gap: 16px; min-width: 0">
<SubagentExample
:steps="steps"
nested
:open="expanded"
@expand="expand"
/>
<p v-if="phase === 'complete'">
Key the cache on a hash of public/images, so unchanged images will be
skipped.
</p>
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
:disabled="phase === 'running'"
@click="replay"
>
Replay
</Button>
</div>
</div>
</template> AI SDK
The research tool returns a nested UIMessage. Its output.parts render recursively, including nested tool calls and text. An output-available part with preliminary: true still runs while the chat streams. It becomes a failure if streaming stops before final output. Final output replaces the preliminary message.
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.
Save as AiSubagentAdapter.vue.
<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceTitle,
TraceIcon,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
type SdkCommandMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: { command: string };
output: { stdout: string; exitCode: number };
};
}
>;
type SdkCommandPart = Extract<
SdkCommandMessage["parts"][number],
{ type: "tool-runCommand" }
>;
function commandState(part: SdkCommandPart, streaming: boolean) {
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 pending = part.state === "approval-requested";
const approved =
part.state === "approval-responded" && part.approval.approved;
const failed =
part.state === "output-error" ||
(finished && part.output.exitCode !== 0) ||
(!denied && !finished && !pending && !approved && !streaming);
const status = denied
? undefined
: pending
? "pending"
: failed
? "error"
: finished
? undefined
: "running";
return {
status,
title: `${denied ? "Denied" : pending ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`,
output:
part.state === "output-error"
? part.errorText
: part.state === "output-available"
? part.output.stdout
: undefined,
} as const;
}
type SdkAgentMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: { command: string };
output: { stdout: string; exitCode: number };
};
research: {
input: { task: string; model?: string };
output: SdkAgentMessage;
};
}
>;
type AgentPart = Extract<
SdkAgentMessage["parts"][number],
{ type: "tool-research" }
>;
const props = withDefaults(
defineProps<{
message: SdkAgentMessage;
streaming: boolean;
nested?: boolean;
}>(),
{ nested: false },
);
function agentState(part: AgentPart) {
const finished = part.state === "output-available" && !part.preliminary;
const pending = part.state === "approval-requested";
const denied =
part.state === "output-denied" ||
(part.state === "approval-responded" && !part.approval.approved);
const failed =
part.state === "output-error" ||
(!finished && !pending && !denied && !props.streaming);
return {
title: `${denied ? "Denied" : pending ? "Waiting for" : failed ? "Failed to run" : finished ? "Ran" : "Running"} research subagent`,
status: pending
? "pending"
: failed
? "error"
: !finished && !denied
? "running"
: undefined,
} as const;
}
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
open.value = { ...open.value, [id]: value };
}
</script>
<template>
<div
:style="
nested
? { display: 'grid', gap: '12px' }
: {
width: '100%',
maxWidth: '384px',
minWidth: 0,
marginInline: 'auto',
display: 'grid',
gap: '16px',
paddingBlock: '24px',
fontSize: 'var(--text-sm)',
lineHeight: 1.5,
}
"
>
<Trace>
<template
v-for="(part, index) in message.parts"
:key="'toolCallId' in part ? part.toolCallId : index"
>
<p v-if="part.type === 'text'">{{ part.text }}</p>
<template v-else-if="part.type === 'tool-runCommand'">
<TraceCollapsible
:unmount-on-hide="false"
v-if="commandState(part, streaming).output !== undefined"
:open="
open[part.toolCallId] ??
commandState(part, streaming).status === 'error'
"
@update:open="setOpen(part.toolCallId, $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="commandState(part, streaming).status">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>
{{ commandState(part, streaming).title }}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap>
{{ commandState(part, streaming).output }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceLine v-else :status="commandState(part, streaming).status">
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>{{ commandState(part, streaming).title }}</TraceTitle>
</TraceLine>
</template>
<TraceCollapsible
:unmount-on-hide="false"
v-else-if="part.type === 'tool-research'"
:open="open[part.toolCallId] ?? true"
@update:open="setOpen(part.toolCallId, $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="agentState(part).status">
<TraceIcon>
<Icon name="Bot" />
</TraceIcon>
<TraceTitle>{{ agentState(part).title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div style="display: grid; gap: 12px">
<div v-if="part.input?.model">
<Badge variant="secondary">{{ part.input.model }}</Badge>
</div>
<p>{{ part.input?.task }}</p>
<CodeBlock
v-if="part.state === 'output-error'"
size="sm"
variant="destructive"
>
<CodeBlockContent wrap>{{ part.errorText }}</CodeBlockContent>
</CodeBlock>
<AiSubagentAdapter
v-if="part.state === 'output-available'"
:message="part.output"
:streaming="streaming && part.preliminary === true"
nested
/>
</div>
</TraceCollapsibleContent>
</TraceCollapsible>
</template>
</Trace>
</div>
</template> Running
Save as SubagentRunningExample.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace";
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
min-width: 0;
margin-inline: auto;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Trace>
<TraceCollapsible default-open :unmount-on-hide="false">
<TraceCollapsibleTrigger>
<TraceLine status="running">
<TraceIcon>
<Icon name="LoaderCircle" />
</TraceIcon>
<TraceTitle>Running the research agent</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
</TraceLine>
<TraceLine status="running">
<TraceIcon>
<Icon name="LoaderCircle" />
</TraceIcon>
<TraceTitle>
Running sed -n 13,15p scripts/optimize-images.ts
</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</div>
</template> Details
Save as SubagentDetailsExample.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceDescription,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace";
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
min-width: 0;
margin-inline: auto;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Trace>
<TraceCollapsible default-open :unmount-on-hide="false">
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>Ran the research agent</TraceTitle>
<TraceDescription>48s</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div>
<Badge variant="secondary">anthropic/claude-sonnet-4.5</Badge>
<Badge variant="outline">runCommand</Badge>
</div>
<p>Find out why image optimization reruns on every deploy.</p>
<Trace>
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
</TraceLine>
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>
Ran sed -n 13,15p scripts/optimize-images.ts
</TraceTitle>
</TraceLine>
</Trace>
<p>The cache is keyed on the commit SHA.</p>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</div>
</template> Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | Reka UI API |
code-block | Reka UI API |
badge | Reka UI API |
icon | Reka UI API |
button | Reka UI API |
message | Reka UI API |
bubble | Reka UI API |