- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Code Block
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Form
- Hover Card
- Input
- Input Group
- Input OTP
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Pin Input
- Popover
- Progress
- Questionnaire
- Radio Group
- Range Calendar
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Stepper
- Switch
- Table
- Tabs
- Tags Input
- Textarea
- Toast
- Toggle
- Toggle Group
- Tooltip
- Trace
- Typography
Migrated Dialog and AlertDialog. The type checker passes.
<script setup lang="ts">
import {
CircleCheckIcon,
CircleDotIcon,
CircleIcon,
RouteIcon,
} from '@lucide/vue'
import { createReusableTemplate, promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, reactive, ref } 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 @base-ui/react',
'Replace the Radix imports',
'Rename asChild to render',
'Run the type checker',
]
const QUESTION = 'Migrate the dialog components to Base UI.'
const ANSWER = 'Migrated Dialog and AlertDialog. The type checker passes.'
function planAt(current: number): PlanData {
return {
title: 'Plan: migrate to Base UI',
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')
// Unmounting bumps `run`, so a running script stops at its next wait.
let run = 0
tryOnScopeDispose(() => run++)
async function sleep(ms: number) {
const id = run
await promiseTimeout(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.at(-1)
}
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'
// A data part with the same id replaces the previous one in place.
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'
}
const [DefinePlanRow, PlanRow] = createReusableTemplate<{
plan: PlanData
streaming: boolean
}>()
</script>
<template>
<DefinePlanRow v-slot="{ plan, streaming }">
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine :status="streaming && countDone(plan) < plan.steps.length ? 'running' : undefined">
<TraceIcon>
<RouteIcon />
</TraceIcon>
<TraceTitle>{{ plan.title }}</TraceTitle>
<TraceDescription>
{{ countDone(plan) }} 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'
: streaming && step.status === 'running'
? 'running'
: undefined"
>
<TraceIcon>
<CircleCheckIcon v-if="step.status === 'done'" />
<CircleDotIcon v-else-if="step.status === 'running'" />
<CircleIcon v-else />
</TraceIcon>
<TraceTitle>{{ step.title }}</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</DefinePlanRow>
<div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
<template v-for="message in messages" :key="message.id">
<Message v-if="message.role === 'user'" align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ getText(message) }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message v-else>
<MessageContent class="gap-4">
<Trace v-if="getPlan(message)">
<PlanRow :plan="getPlan(message)!" :streaming="isStreaming(message)" />
</Trace>
<p v-if="getText(message)">
{{ getText(message) }}
</p>
</MessageContent>
</Message>
</template>
<Button
v-if="!busy"
variant="outline"
size="sm"
class="mt-auto self-start"
@click="replay"
>
Replay
</Button>
</div>
</template>Plan is the list of steps the agent decides on before it starts, such as the steps of a migration. Each step is done, running or pending, and the title counts how many are done.
Plan is built with Trace with a TraceCollapsible for the plan.
When the steps are not known up front, use Task instead.
Installation
Plan is built with the Trace component.
pnpm dlx shadcn-vue@latest add trace
Usage
<script setup lang="ts">
import {
CircleCheckIcon,
CircleDotIcon,
CircleIcon,
RouteIcon,
} from '@lucide/vue'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceDescription,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
</script>
<template>
<Trace>
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine status="running">
<TraceIcon>
<RouteIcon />
</TraceIcon>
<TraceTitle>Plan: migrate to Base UI</TraceTitle>
<TraceDescription>2 of 5 done</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine>
<TraceIcon>
<CircleCheckIcon />
</TraceIcon>
<TraceTitle>Replace the Radix imports</TraceTitle>
</TraceLine>
<TraceLine status="running">
<TraceIcon>
<CircleDotIcon />
</TraceIcon>
<TraceTitle>Rename asChild to render</TraceTitle>
</TraceLine>
<TraceLine status="pending">
<TraceIcon>
<CircleIcon />
</TraceIcon>
<TraceTitle>Run the type checker</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</template>Details
Put a description above the steps, inside TraceCollapsibleContent.
Store the theme in a cookie so the server renders the right one and the page does not flash on load.
<script setup lang="ts">
import { CircleCheckIcon, CircleIcon, RouteIcon } from '@lucide/vue'
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 class="max-w-sm">
<TraceCollapsible default-open>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<RouteIcon />
</TraceIcon>
<TraceTitle>Plan: add dark mode</TraceTitle>
<TraceDescription>1 of {{ steps.length }} done</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent class="flex flex-col gap-3">
<p>
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>
<CircleCheckIcon v-if="index === 0" />
<CircleIcon v-else />
</TraceIcon>
<TraceTitle>{{ step }}</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</template>AI SDK
Stream the plan as a data part with a fixed id. The AI SDK replaces a data part with the same id, so it updates in place.
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: 'data-plan', id: 'plan', data: { title, steps } })
},
})const plan = computed(() =>
props.message.parts.find(part => part.type === 'data-plan'),
)Migrating from AI Elements Vue
If you added Plan from AI Elements Vue, your project has a components/ai-elements/plan/ folder. Paste this prompt into your coding agent to rebuild it on Trace. Every export keeps its name and props, so the code that uses it keeps working.
Migrate components/ai-elements/plan/ from AI Elements Vue to the shadcn-vue Trace component.
Keep every export, with the same name, props and emits, so the code that imports it keeps working. Only change how each part renders.
1. Install Trace: npx shadcn-vue@latest add trace
2. Read https://www.shadcn-vue.com/docs/ai-elements/plan and build Plan the same way.
3. Rebuild each part:
- Plan.vue renders a Trace with a TraceCollapsible, instead of a Card. Provide isStreaming to the other parts.
- PlanHeader.vue renders a TraceCollapsibleTrigger with a TraceLine. Set status="running" on the TraceLine while isStreaming is true, instead of the Shimmer.
- PlanTitle.vue renders a TraceTitle, and PlanDescription.vue a TraceDescription, like "2 of 4 done".
- PlanContent.vue renders a TraceCollapsibleContent with a Trace for the steps.
- PlanTrigger.vue renders only its slot, since the whole line opens the plan.
- Buttons cannot sit inside a trigger. Render PlanAction.vue next to the TraceCollapsibleTrigger, not inside it.
- Keep PlanFooter.vue as a plain container.
4. Remove the imports the files no longer use.
5. Run vue-tsc and fix any errors.API Reference
See Trace.
Sponsors
Your logo hereshadcn-vue is free and open source. Sponsorship keeps it maintained.
Become a sponsor