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 A todo moves to Completed when it finishes. Only the active todo shimmers while the stream runs. A todo with a description has its own disclosure.
Usage
Complete example
Save as AiQueueExample.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";
interface Todo {
content: string;
status: "pending" | "in_progress" | "completed";
}
const props = withDefaults(defineProps<{ question?: string }>(), {
question: "Add dark mode to the settings page.",
});
const QUESTION = props.question;
const ANSWER = "Dark mode is in. The toggle is under Settings, Appearance.";
const TODOS = [
"Add a dark variant to the theme tokens",
"Read the theme from a cookie",
"Add a toggle to the settings page",
"Check contrast on the charts",
];
function todosAt(current: number): Todo[] {
return TODOS.map((content, index) => ({
content,
status:
index < current
? "completed"
: index === current
? "in_progress"
: "pending",
}));
}
const todos = ref<Todo[] | undefined>(todosAt(TODOS.length));
const text = ref(ANSWER);
const busy = ref(false);
const sections = computed(() => {
const list = todos.value ?? [];
const pending = list.filter((todo) => todo.status !== "completed");
const completed = list.filter((todo) => todo.status === "completed");
return [
{
id: "pending",
icon: "ListTodo",
title: `${pending.length} to do`,
todos: pending,
},
{
id: "completed",
icon: "ListChecks",
title: `${completed.length} completed`,
todos: completed,
},
].filter((section) => section.todos.length > 0);
});
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;
}
async function replay() {
const id = ++run;
busy.value = true;
todos.value = undefined;
text.value = "";
for (let index = 0; index < TODOS.length; index++) {
todos.value = todosAt(index);
if (!(await wait(1400, id))) return;
}
todos.value = todosAt(TODOS.length);
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="todos || text">
<MessageContent style="gap: 16px">
<Trace v-if="todos">
<TraceCollapsible
v-for="section in sections"
:key="section.id"
default-open
>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon :name="section.icon" />
</TraceIcon>
<TraceTitle>{{ section.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine
v-for="todo in section.todos"
:key="todo.content"
:status="
busy && todo.status === 'in_progress'
? 'running'
: undefined
"
>
<TraceIcon>
<Icon
name="CircleCheck"
v-if="todo.status === 'completed'"
/>
<Icon
name="CircleDot"
v-else-if="todo.status === 'in_progress'"
/>
<Icon name="Circle" v-else />
</TraceIcon>
<TraceTitle>{{ todo.content }}</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> Descriptions
Save as AiQueueDescriptions.vue.
<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace";
const todos: { title: string; description?: string; done: boolean }[] = [
{
title: "Write the migration guide",
description: "Cover the renamed props and the new data attributes.",
done: true,
},
{ title: "Add a changeset", done: true },
{
title: "Fix the focus ring on the select trigger",
description: "It is clipped by overflow-hidden on the field.",
done: false,
},
{ title: "Update the registry snapshots", done: false },
{
title: "Add tests for the combobox",
description: "Typing, clearing and keyboard selection.",
done: false,
},
];
const sections = [
{
id: "pending",
icon: "ListTodo",
title: `${todos.filter((todo) => !todo.done).length} to do`,
todos: todos.filter((todo) => !todo.done),
defaultOpen: true,
},
{
id: "completed",
icon: "ListChecks",
title: `${todos.filter((todo) => todo.done).length} completed`,
todos: todos.filter((todo) => todo.done),
defaultOpen: false,
},
];
</script>
<template>
<Trace
style="
width: 100%;
max-width: 384px;
margin-inline: auto;
padding-block: 24px;
"
>
<TraceCollapsible
v-for="section in sections"
:key="section.id"
:unmount-on-hide="false"
:default-open="section.defaultOpen"
>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon :name="section.icon" />
</TraceIcon>
<TraceTitle>{{ section.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<template v-for="todo in section.todos" :key="todo.title">
<TraceCollapsible v-if="todo.description" :unmount-on-hide="false">
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon v-if="todo.done" name="CircleCheck" />
<Icon v-else name="Circle" />
</TraceIcon>
<TraceTitle>{{ todo.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
{{ todo.description }}
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceLine v-else>
<TraceIcon>
<Icon v-if="todo.done" name="CircleCheck" />
<Icon v-else name="Circle" />
</TraceIcon>
<TraceTitle>{{ todo.title }}</TraceTitle>
</TraceLine>
</template>
</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 last tool-writeTodos call whose input is no longer streaming. Each call contains the complete todo list. Split it into pending and completed sections. Only show a running line while the chat streams.
Save as AiQueueAdapter.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,
TraceIcon,
} from "@/components/ui/trace";
interface Todo {
content: string;
status: "pending" | "in_progress" | "completed";
}
type QueueMessage = UIMessage<
unknown,
never,
{ writeTodos: { input: { todos: Todo[] }; output: { saved: boolean } } }
>;
const props = withDefaults(
defineProps<{ message?: QueueMessage; streaming?: boolean }>(),
{
streaming: false,
message: () => ({
id: "answer",
role: "assistant",
parts: [
{
type: "tool-writeTodos",
toolCallId: "todos-1",
state: "input-available",
input: {
todos: [
{
content: "Add a dark variant to the theme tokens",
status: "completed",
},
{ content: "Read the theme from a cookie", status: "pending" },
{
content: "Add a toggle to the settings page",
status: "pending",
},
{ content: "Check contrast on the charts", status: "pending" },
],
},
},
],
}),
},
);
const todos = computed(
() =>
props.message.parts
.filter((part) => part.type === "tool-writeTodos")
.filter((part) => part.state !== "input-streaming")
.slice(-1)[0]?.input?.todos,
);
const sections = computed(() => {
const list = todos.value ?? [];
const pending = list.filter((todo) => todo.status !== "completed");
const completed = list.filter((todo) => todo.status === "completed");
return [
{
id: "pending",
icon: "ListTodo",
title: `${pending.length} to do`,
todos: pending,
},
{
id: "completed",
icon: "ListChecks",
title: `${completed.length} completed`,
todos: completed,
},
].filter((section) => section.todos.length > 0);
});
</script>
<template>
<div
style="
width: 100%;
max-width: 384px;
margin-inline: auto;
padding-block: 24px;
"
>
<Trace v-if="todos">
<TraceCollapsible
v-for="section in sections"
:key="section.id"
default-open
>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon :name="section.icon" />
</TraceIcon>
<TraceTitle>{{ section.title }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine
v-for="todo in section.todos"
:key="todo.content"
:status="
props.streaming && todo.status === 'in_progress'
? 'running'
: undefined
"
>
<TraceIcon>
<Icon name="CircleCheck" v-if="todo.status === 'completed'" />
<Icon
name="CircleDot"
v-else-if="todo.status === 'in_progress'"
/>
<Icon name="Circle" v-else />
</TraceIcon>
<TraceTitle>{{ todo.content }}</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 |