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 Task when each step becomes known during the work. A running task opens, then closes when complete, unless the user chooses another state. A failed or interrupted step makes its task an error. Plan is for steps known in advance.
Usage
Complete example
Save as AiTaskExample.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { computed, 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,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace";
type Status = "running" | "error" | undefined;
interface TaskData {
title: string;
steps: { title: string; status: "done" | "running" | "error" }[];
}
const props = withDefaults(defineProps<{ question?: string }>(), {
question: "Add Open Graph metadata to the home page.",
});
const QUESTION = props.question;
const ANSWER =
"Added Open Graph defaults to app/app.vue and a title, description and image for the home page in app/pages/index.vue.";
const TASKS = [
{
id: "find",
title: ["Finding the project files", "Found the project files"],
steps: [
["Searching for useSeoMeta", "Searched for useSeoMeta"],
["Reading app/app.vue", "Read app/app.vue"],
["Reading app/pages/index.vue", "Read app/pages/index.vue"],
],
},
{
id: "update",
title: ["Updating the page metadata", "Updated the page metadata"],
steps: [
["Editing app/app.vue", "Edited app/app.vue"],
["Editing app/pages/index.vue", "Edited app/pages/index.vue"],
],
},
];
function taskAt(task: (typeof TASKS)[number], done: number): TaskData {
return {
title: task.title[done < task.steps.length ? 0 : 1]!,
steps: task.steps.slice(0, done + 1).map(([running, finished], index) => ({
title: (index < done ? finished : running)!,
status: index < done ? "done" : "running",
})),
};
}
function finalTasks() {
return TASKS.map((task) => ({
id: task.id,
data: taskAt(task, task.steps.length),
}));
}
const tasks = ref<{ id: string; data: TaskData }[]>(finalTasks());
const text = ref(ANSWER);
const busy = ref(false);
const openState = ref<Record<string, boolean>>({});
function getStepStatus(
status: TaskData["steps"][number]["status"],
streaming: boolean,
): Status {
if (status === "done") return undefined;
return status === "running" && streaming ? "running" : "error";
}
const rows = computed(() =>
tasks.value.map((task) => {
const steps = task.data.steps.map((step) => ({
title: step.title,
status: getStepStatus(step.status, busy.value),
}));
const status: Status = steps.some((step) => step.status === "error")
? "error"
: steps.some((step) => step.status === "running")
? "running"
: undefined;
return { id: task.id, title: task.data.title, steps, status };
}),
);
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 wait(ms: number, id: number) {
await pause(ms);
return id === run;
}
function writeTask(id: string, data: TaskData) {
const index = tasks.value.findIndex((task) => task.id === id);
if (index === -1) tasks.value.push({ id, data });
else tasks.value[index] = { id, data };
}
async function replay() {
const id = ++run;
busy.value = true;
tasks.value = [];
text.value = "";
openState.value = {};
for (const task of TASKS) {
for (let done = 0; done <= task.steps.length; done++) {
writeTask(task.id, taskAt(task, done));
if (!(await wait(900, id))) return;
}
}
for (const word of ANSWER.split(/(?<= )/)) {
if (!(await wait(20, id))) return;
text.value += word;
}
busy.value = false;
}
</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;
"
>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ props.question }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message v-if="rows.length > 0 || text">
<MessageContent style="gap: 16px">
<Trace v-if="rows.length > 0">
<TraceCollapsible
v-for="task in rows"
:key="task.id"
:unmount-on-hide="false"
:open="openState[task.id] ?? task.status === 'running'"
@update:open="openState[task.id] = $event"
>
<TraceCollapsibleTrigger>
<TraceLine :status="task.status">
<TraceIcon>
<Icon v-if="task.status === 'running'" name="CircleDot" />
<Icon
v-else-if="task.status === 'error'"
name="CircleAlert"
/>
<Icon v-else name="CircleCheck" />
</TraceIcon>
<TraceTitle>{{ task.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine
v-for="step in task.steps"
:key="step.title"
:status="step.status"
>
<TraceTitle>{{
step.status === "error"
? "Interrupted: " + step.title
: step.title
}}</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
<p v-if="text">
{{ text }}
</p>
</MessageContent>
</Message>
<Button
:disabled="busy"
variant="outline"
size="sm"
style="margin-top: auto; align-self: flex-start"
@click="replay"
>
Replay
</Button>
</div>
</template> Interrupted
Save as AiTaskInterrupted.vue.
<script setup lang="ts">
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceTitle,
TraceIcon,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
const steps = [
{ title: "Read app/pages/index.vue", status: undefined },
{ title: "Interrupted: Save app/pages/index.vue", status: "error" as const },
];
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
margin-inline: auto;
padding-block: 24px;
"
>
<Trace>
<TraceCollapsible :unmount-on-hide="false">
<TraceCollapsibleTrigger>
<TraceLine status="error">
<TraceIcon><Icon name="CircleAlert" /></TraceIcon>
<TraceTitle>Failed to update the page metadata</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine
v-for="step in steps"
:key="step.title"
:status="step.status"
>
<TraceTitle>{{ step.title }}</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</div>
</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 data-task parts and render each task by its part id. On the server, write updates with the same task id to replace the data part in place. Titles and step errors come from the task data. A running step becomes an error when the chat stops before it completes.
Server (server/api/chat.post.ts):
import { createUIMessageStream } from "ai";
const title = "Updated the page metadata";
const steps = [{ title: "Saved app/page.tsx", status: "done" }];
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "data-task", id: "update", data: { title, steps } });
},
});
Pass the resulting message to the client adapter below.
Save as AiTaskAdapter.vue.
<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import { Icon } from "@/components/ui/icon";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceTitle,
TraceIcon,
} from "@/components/ui/trace";
type Status = "running" | "error" | undefined;
interface TaskData {
title: string;
steps: { title: string; status: "done" | "running" | "error" }[];
}
type TaskMessage = UIMessage<unknown, { task: TaskData }>;
const props = withDefaults(
defineProps<{ message?: TaskMessage; streaming?: boolean }>(),
{
streaming: false,
message: () => ({
id: "answer",
role: "assistant",
parts: [
{
type: "data-task",
id: "find",
data: {
title: "Could not find the project files",
steps: [{ title: "Search the project directory", status: "error" }],
},
},
{
type: "data-task",
id: "update",
data: {
title: "Updated the page metadata",
steps: [{ title: "Saved app/pages/index.vue", status: "done" }],
},
},
],
}),
},
);
const openState = ref<Record<string, boolean>>({});
function getStepStatus(
status: TaskData["steps"][number]["status"],
streaming: boolean,
): Status {
if (status === "done") return undefined;
return status === "running" && streaming ? "running" : "error";
}
const rows = computed(() =>
props.message.parts
.filter((part) => part.type === "data-task")
.map((task, index) => {
const steps = task.data.steps.map((step) => ({
title: step.title,
status: getStepStatus(step.status, props.streaming),
}));
const status: Status = steps.some((step) => step.status === "error")
? "error"
: steps.some((step) => step.status === "running")
? "running"
: undefined;
return {
id: task.id ?? String(index),
title: task.data.title,
steps,
status,
};
}),
);
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
margin-inline: auto;
padding-block: 24px;
"
>
<Trace v-if="rows.length > 0">
<TraceCollapsible
v-for="task in rows"
:key="task.id"
:unmount-on-hide="false"
:open="openState[task.id] ?? task.status === 'running'"
@update:open="openState[task.id] = $event"
>
<TraceCollapsibleTrigger>
<TraceLine :status="task.status">
<TraceIcon>
<Icon v-if="task.status === 'running'" name="CircleDot" />
<Icon v-else-if="task.status === 'error'" name="CircleAlert" />
<Icon v-else name="CircleCheck" />
</TraceIcon>
<TraceTitle>{{ task.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine
v-for="step in task.steps"
:key="step.title"
:status="step.status"
>
<TraceTitle>{{
step.status === "error"
? "Interrupted: " + step.title
: 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 |