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 Use a nested Trace for each group of steps. A running step uses the present tense; a complete step uses the past tense. Keep disclosure choices in application state so nested steps keep their open state when a parent closes. Map tool output to these records; no AI provider is required.
Usage
Complete example
Save as ChainOfThoughtExample.vue.
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from "vue";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} 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 Step = {
id: string;
title: string;
status?: "running" | "error" | "pending";
output?: string;
files?: string[];
href?: string;
children?: Step[];
defaultOpen?: boolean;
};
const props = withDefaults(
defineProps<{
state?: Phase;
nested?: boolean;
steps?: Step[];
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<Step[]>(() =>
phase.value === "pending"
? [
{
id: "waiting",
title: "Waiting to research the deployment",
status: "pending",
},
]
: [
{
id: "log",
defaultOpen: true,
title: "Read the build log for dpl_8Kx2",
output: "install 41s\ncompile 38s\nimages 3m 52s",
},
{
id: "web",
title:
phase.value === "running"
? "Searching the web for image cache keys"
: phase.value === "error"
? "Failed to search the web"
: "Searched the web for image cache keys",
status:
phase.value === "running"
? "running"
: phase.value === "error"
? "error"
: undefined,
output:
phase.value === "error"
? "Search timed out. Try again."
: undefined,
children:
phase.value === "complete"
? [
{
id: "image-docs",
title: "Image component · nuxt.com",
href: "https://nuxt.com/docs/guide/directory-structure/public",
},
{
id: "cache-docs",
title: "Build cache · vercel.com",
href: "https://vercel.com/docs/deployments/troubleshoot-a-build",
},
]
: undefined,
},
{
id: "code",
title:
phase.value === "complete"
? "Searched the code for cacheKey"
: "Search the code for cacheKey",
status: phase.value === "complete" ? undefined : "pending",
files:
phase.value === "complete"
? ["nuxt.config.ts", "scripts/optimize-images.ts"]
: undefined,
},
],
);
function icon(step: Step) {
return step.href
? "Globe"
: step.status === "error"
? "CircleAlert"
: step.status === "running"
? "LoaderCircle"
: step.status === "pending"
? "CircleDashed"
: "CircleCheck";
}
</script>
<template>
<Trace v-if="nested">
<template v-for="step in props.steps" :key="step.id">
<a
v-if="step.href"
:href="step.href"
target="_blank"
rel="noreferrer"
:aria-label="step.title + ' (opens in a new tab)'"
>
<TraceLine :status="step.status">
<TraceIcon>
<Icon :name="icon(step)" />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
</TraceLine>
</a>
<TraceCollapsible
:unmount-on-hide="false"
v-else-if="step.output || step.files || step.children"
:open="open[step.id] ?? step.defaultOpen === true"
@update:open="emit('expand', step.id, $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="step.status">
<TraceIcon>
<Icon :name="icon(step)" />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock
v-if="step.output"
size="sm"
:variant="step.status === 'error' ? 'destructive' : 'default'"
>
<CodeBlockContent wrap style="max-height: 256px">
{{ step.output }}
</CodeBlockContent>
</CodeBlock>
<div
v-if="step.files"
style="display: flex; flex-wrap: wrap; gap: 6px"
>
<Badge v-for="file in step.files" :key="file" variant="secondary">
{{ file }}
</Badge>
</div>
<ChainOfThoughtExample
v-if="step.children"
:steps="step.children"
nested
:open="open"
@expand="(id, value) => emit('expand', id, value)"
/>
</TraceCollapsibleContent>
</TraceCollapsible>
<template v-else>
<TraceLine :status="step.status">
<TraceIcon>
<Icon :name="icon(step)" />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
</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 is the checkout deploy so slow?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style="gap: 16px; min-width: 0">
<Trace>
<TraceCollapsible
:unmount-on-hide="false"
:open="expanded.summary ?? phase !== 'complete'"
@update:open="expand('summary', $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="phase === 'complete' ? undefined : phase">
<TraceIcon>
<Icon
:name="
phase === 'error'
? 'CircleAlert'
: phase === 'running'
? 'LoaderCircle'
: 'CircleCheck'
"
/>
</TraceIcon>
<TraceTitle>
{{
phase === "complete"
? "Researched in 3 steps"
: phase === "running"
? "Researching"
: phase === "pending"
? "Research queued"
: "Research interrupted"
}}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<ChainOfThoughtExample
:steps="steps"
nested
:open="expanded"
@expand="expand"
/>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
<p v-if="phase === 'complete'">
Image optimization takes almost four minutes because its cache key
includes the commit hash. Key it on the image directory instead.
</p>
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
:disabled="phase === 'running'"
@click="replay"
>
Replay
</Button>
</div>
</div>
</template> AI SDK
The adapter reads searchDocs, readFile, and runCommand tool parts from a typed UIMessage. It opens the summary while tool parts stream and before a text answer begins. Failed steps determine the summary status. Each call keeps its toolCallId and its own disclosure state.
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 AiChainOfThoughtAdapter.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";
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 SdkChainMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: { command: string };
output: { stdout: string; exitCode: number };
};
searchDocs: { input: { query: string }; output: { summary: string } };
readFile: { input: { path: string }; output: { content: string } };
}
>;
type SdkStepPart = Extract<
SdkChainMessage["parts"][number],
{ type: `tool-${string}` }
>;
function researchStep(
part: SdkStepPart,
streaming: boolean,
): {
id: string;
title: string;
status?: "pending" | "running" | "error";
output?: string;
} {
if (part.type === "tool-runCommand")
return { id: part.toolCallId, ...commandState(part, streaming) };
const finished = part.state === "output-available" && !part.preliminary;
const denied =
part.state === "output-denied" ||
(part.state === "approval-responded" && !part.approval.approved);
const pending = part.state === "approval-requested";
const failed =
part.state === "output-error" ||
(!finished && !pending && !denied && !streaming);
const verb =
part.type === "tool-searchDocs"
? finished
? "Searched"
: "Searching"
: finished
? "Read"
: "Reading";
const action =
part.type === "tool-searchDocs"
? (part.input?.query ?? "documentation")
: (part.input?.path ?? "file");
const output =
part.state === "output-error"
? part.errorText
: part.state === "output-available"
? part.type === "tool-searchDocs"
? part.output.summary
: part.output.content
: undefined;
return {
id: part.toolCallId,
title: `${denied ? "Denied" : pending ? "Waiting to" : failed ? "Failed to" : verb} ${denied || pending || failed ? (part.type === "tool-searchDocs" ? "search " : "read ") : ""}${action}`,
status: denied
? undefined
: pending
? "pending"
: failed
? "error"
: finished
? undefined
: "running",
output,
};
}
const props = defineProps<{
message: SdkChainMessage;
part?: SdkStepPart;
streaming: boolean;
}>();
const steps = computed(() =>
props.message.parts.filter(
(part): part is SdkStepPart =>
part.type === "tool-runCommand" ||
part.type === "tool-searchDocs" ||
part.type === "tool-readFile",
),
);
const running = computed(
() =>
props.streaming &&
!props.message.parts.some(
(part) => part.type === "text" && part.text.trim().length > 0,
),
);
const failed = computed(
() =>
steps.value.filter(
(part) => researchStep(part, props.streaming).status === "error",
).length,
);
const row = computed(() =>
props.part ? researchStep(props.part, props.streaming) : undefined,
);
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
open.value = { ...open.value, [id]: value };
}
</script>
<template>
<template v-if="part && row">
<TraceCollapsible
:unmount-on-hide="false"
v-if="row.output !== undefined"
:open="open[row.id] ?? row.status === 'error'"
@update:open="setOpen(row.id, $event)"
>
<TraceCollapsibleTrigger>
<TraceLine :status="row.status">
<TraceIcon>
<Icon :name="row.status === 'error' ? 'CircleAlert' : 'Terminal'" />
</TraceIcon>
<TraceTitle>{{ row.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock
size="sm"
:variant="row.status === 'error' ? 'destructive' : 'default'"
>
<CodeBlockContent wrap style="max-height: 256px">
{{ row.output }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<template v-else>
<TraceLine :status="row.status">
<TraceIcon>
<Icon :name="row.status === 'error' ? 'CircleAlert' : 'Terminal'" />
</TraceIcon>
<TraceTitle>{{ row.title }}</TraceTitle>
</TraceLine>
</template>
</template>
<div
v-else
style="
width: 100%;
max-width: 384px;
min-width: 0;
margin-inline: auto;
display: grid;
gap: 16px;
padding-block: 24px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<Trace>
<TraceCollapsible
:unmount-on-hide="false"
:open="open.summary ?? running"
@update:open="setOpen('summary', $event)"
>
<TraceCollapsibleTrigger>
<TraceLine
:status="running ? 'running' : failed ? 'error' : undefined"
>
<TraceTitle>
{{
running
? "Researching"
: `Researched in ${steps.length} steps${failed ? `, ${failed} failed` : ""}`
}}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<AiChainOfThoughtAdapter
:message="message"
v-for="step in steps"
:key="step.toolCallId"
:part="step"
:streaming="streaming"
/>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
<template v-for="(item, index) in message.parts" :key="index">
<p v-if="item.type === 'text'">{{ item.text }}</p>
</template>
</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 |