Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download trace collapsible message bubble button icon -f vue --primitive reka Use Plan when the steps are known before work starts. Completed, running, and pending steps remain in order. A task adds steps as work occurs.
Usage
Complete example
Save as AiPlanExample.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { computed, reactive, ref, onScopeDispose } from "vue";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceDescription,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace";
interface PlanData {
title: string;
steps: { title: string; status: "done" | "running" | "pending" }[];
}
interface PlanPart {
type: "data-plan";
id: string;
data: PlanData;
}
interface TextPart {
type: "text";
text: string;
}
interface ChatMessage {
id: string;
role: "user" | "assistant";
parts: (PlanPart | TextPart)[];
}
const STEPS = [
"Install the design system components",
"Replace the dialog imports",
"Connect the dialog state",
"Run the type checker",
];
const props = withDefaults(defineProps<{ question?: string }>(), {
question: "Migrate the dialog components to the new design system.",
});
const QUESTION = props.question;
const ANSWER = "Migrated Dialog and AlertDialog. The type checker passes.";
function planAt(current: number): PlanData {
return {
title: "Plan: migrate the dialogs",
steps: STEPS.map((title, index) => ({
title,
status:
index < current ? "done" : index === current ? "running" : "pending",
})),
};
}
function transcript(): ChatMessage[] {
return [
{ id: "user-0", role: "user", parts: [{ type: "text", text: QUESTION }] },
{
id: "assistant-0",
role: "assistant",
parts: [
{ type: "data-plan", id: "plan", data: planAt(STEPS.length) },
{ type: "text", text: ANSWER },
],
},
];
}
const messages = ref<ChatMessage[]>(transcript());
const status = ref<"ready" | "submitted" | "streaming">("ready");
const busy = computed(() => status.value !== "ready");
let run = 0;
const timers = new Set<ReturnType<typeof setTimeout>>();
function pause(ms: number) {
return new Promise<void>((resolve) => {
const timer = setTimeout(() => {
timers.delete(timer);
resolve();
}, ms);
timers.add(timer);
});
}
onScopeDispose(() => {
run++;
for (const timer of timers) clearTimeout(timer);
timers.clear();
});
async function sleep(ms: number) {
const id = run;
await pause(ms);
return id === run;
}
async function streamInto(
target: { text: string },
text: string,
delayMs: number,
) {
for (const chunk of text.match(/\S+\s*/g) ?? []) {
target.text += chunk;
if (!(await sleep(delayMs))) return false;
}
return true;
}
function getText(message: ChatMessage) {
return message.parts
.filter((part): part is TextPart => part.type === "text")
.map((part) => part.text)
.join("");
}
function getPlan(message: ChatMessage) {
return message.parts.find(
(part): part is PlanPart => part.type === "data-plan",
)?.data;
}
function isStreaming(message: ChatMessage) {
return busy.value && message === messages.value.slice(-1)[0];
}
function countDone(plan: PlanData) {
return plan.steps.filter((step) => step.status === "done").length;
}
async function replay() {
const id = ++run;
messages.value = [
{
id: `user-${id}`,
role: "user",
parts: [{ type: "text", text: QUESTION }],
},
];
status.value = "submitted";
if (!(await sleep(300))) return;
const message = reactive<ChatMessage>({
id: `assistant-${id}`,
role: "assistant",
parts: [],
});
messages.value.push(message);
status.value = "streaming";
const plan = reactive<PlanPart>({
type: "data-plan",
id: "plan",
data: planAt(0),
});
message.parts.push(plan);
for (let index = 1; index <= STEPS.length; index++) {
if (!(await sleep(1200))) return;
plan.data = planAt(index);
}
const text = reactive<TextPart>({ type: "text", text: "" });
message.parts.push(text);
if (!(await streamInto(text, ANSWER, 20))) return;
status.value = "ready";
}
</script>
<template>
<div
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;
"
>
<template v-for="message in messages" :key="message.id">
<Message v-if="message.role === 'user'" align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ props.question }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message v-else>
<MessageContent style="gap: 16px">
<Trace v-if="getPlan(message)">
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine
:status="
isStreaming(message) &&
countDone(getPlan(message)!) <
getPlan(message)!.steps.length
? 'running'
: undefined
"
>
<TraceIcon>
<Icon name="Route" />
</TraceIcon>
<TraceTitle>{{ getPlan(message)!.title }}</TraceTitle>
<TraceDescription>
{{ countDone(getPlan(message)!) }} of
{{ getPlan(message)!.steps.length }} done
</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine
v-for="step in getPlan(message)!.steps"
:key="step.title"
:status="
step.status === 'pending'
? 'pending'
: isStreaming(message) && step.status === 'running'
? 'running'
: undefined
"
>
<TraceIcon>
<Icon name="CircleCheck" v-if="step.status === 'done'" />
<Icon
name="CircleDot"
v-else-if="step.status === 'running'"
/>
<Icon name="Circle" v-else />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
<p v-if="getText(message)">
{{ getText(message) }}
</p>
</MessageContent>
</Message>
</template>
<Button
:disabled="busy"
variant="outline"
size="sm"
style="margin-top: auto; align-self: flex-start"
@click="replay"
>
Replay
</Button>
</div>
</template> Details
Save as AiPlanDetails.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceDescription,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace";
const steps = [
"Add a dark variant to the theme tokens",
"Read the theme from a cookie on the server",
"Add a toggle to the settings page",
"Check contrast on the charts",
];
</script>
<template>
<Trace
style="
width: 100%;
max-width: 384px;
margin-inline: auto;
padding-block: 24px;
"
>
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Route" />
</TraceIcon>
<TraceTitle>Plan: add dark mode</TraceTitle>
<TraceDescription>1 of {{ steps.length }} done</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<p style="margin-bottom: 12px">
Store the theme in a cookie so the server renders the right one and
the page does not flash on load.
</p>
<Trace>
<TraceLine
v-for="(step, index) in steps"
:key="step"
:status="index > 0 ? 'pending' : undefined"
>
<TraceIcon>
<Icon name="CircleCheck" v-if="index === 0" />
<Icon name="Circle" v-else />
</TraceIcon>
<TraceTitle>{{ step }}</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</template> AI SDK
This optional adapter accepts a UIMessage from your chat client. The complete example needs no AI SDK or provider. Install this extra package only for the adapter:
pnpm add ai
Read the data-plan part. On the server, write each update with the same part id, such as plan. The AI SDK replaces that data part in place. The adapter renders its title, steps, and completed count.
Server (server/api/chat.post.ts):
import { createUIMessageStream } from "ai";
const title = "Plan: migrate the dialogs";
const steps = [{ title: "Update the dialog imports", status: "pending" }];
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "data-plan", id: "plan", data: { title, steps } });
},
});
Pass the resulting message to the client adapter below.
Save as AiPlanAdapter.vue.
<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed } from "vue";
import { Icon } from "@/components/ui/icon";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceTitle,
TraceDescription,
TraceIcon,
} from "@/components/ui/trace";
interface PlanData {
title: string;
steps: { title: string; status: "done" | "running" | "pending" }[];
}
type PlanMessage = UIMessage<unknown, { plan: PlanData }>;
const props = withDefaults(
defineProps<{ message?: PlanMessage; streaming?: boolean }>(),
{
streaming: false,
message: () => ({
id: "answer",
role: "assistant",
parts: [
{
type: "data-plan",
id: "plan",
data: {
title: "Plan: migrate the dialogs",
steps: [
{ title: "Install the design system components", status: "done" },
{ title: "Replace the dialog imports", status: "pending" },
{ title: "Connect the dialog state", status: "pending" },
{ title: "Run the type checker", status: "pending" },
],
},
},
],
}),
},
);
const plan = computed(
() => props.message.parts.find((part) => part.type === "data-plan")?.data,
);
const done = computed(
() => plan.value?.steps.filter((step) => step.status === "done").length ?? 0,
);
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
margin-inline: auto;
padding-block: 24px;
"
>
<Trace v-if="plan">
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine
:status="
props.streaming && done < plan.steps.length
? 'running'
: undefined
"
>
<TraceIcon>
<Icon name="Route" />
</TraceIcon>
<TraceTitle>{{ plan.title }}</TraceTitle>
<TraceDescription>
{{ done }} of {{ plan.steps.length }} done
</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine
v-for="step in plan.steps"
:key="step.title"
:status="
step.status === 'pending'
? 'pending'
: props.streaming && step.status === 'running'
? 'running'
: undefined
"
>
<TraceIcon>
<Icon name="CircleCheck" v-if="step.status === 'done'" />
<Icon name="CircleDot" v-else-if="step.status === 'running'" />
<Icon name="Circle" v-else />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
</TraceLine>
</Trace>
</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 |
collapsible | Reka UI API |
message | Reka UI API |
bubble | Reka UI API |
button | Reka UI API |
icon | Reka UI API |