- 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
install 41s
compile 38s
images 3m 52sImage optimization takes almost four minutes because its cache key includes the commit hash. Key it on the image directory instead.
<script setup lang="ts">
import {
ArrowUpRightIcon,
CircleAlertIcon,
CircleCheckIcon,
GlobeIcon,
LoaderCircleIcon,
} from '@lucide/vue'
import { createReusableTemplate, promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import { Message, MessageContent } from '@/components/ui/message'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
type ToolState = 'input-available' | 'output-available' | 'output-error'
type Status = 'running' | 'error' | undefined
interface ToolPartBase<TType extends string, TInput, TOutput> {
type: TType
toolCallId: string
state: ToolState
input: TInput
output?: TOutput
errorText?: string
}
type StepPart
= | ToolPartBase<'tool-readBuildLog', { deployment: string }, { log: string }>
| ToolPartBase<'tool-searchWeb', { query: string }, { results: { title: string, url: string }[] }>
| ToolPartBase<'tool-searchCode', { query: string }, { files: string[] }>
interface TextPart {
type: 'text'
text: string
}
interface ChatMessage {
id: string
role: 'user' | 'assistant'
parts: (StepPart | TextPart)[]
}
const QUESTION = 'Why is the checkout deploy so slow?'
const ANSWER = 'Image optimization takes almost four minutes because its cache key includes the commit hash. Key it on the image directory instead.'
// The steps the agent takes, in order, with their output.
const SCRIPT: Omit<StepPart, 'toolCallId' | 'state'>[] = [
{
type: 'tool-readBuildLog',
input: { deployment: 'dpl_8Kx2' },
output: { log: 'install 41s\ncompile 38s\nimages 3m 52s' },
},
{
type: 'tool-searchWeb',
input: { query: 'next.js image cache key' },
output: {
results: [
{
title: 'Image component | nextjs.org',
url: 'https://nextjs.org/docs/app/api-reference/components/image',
},
{
title: 'Build cache | vercel.com',
url: 'https://vercel.com/docs/deployments/troubleshoot-a-build',
},
],
},
},
{
type: 'tool-searchCode',
input: { query: 'cacheKey' },
output: { files: ['next.config.ts', 'scripts/optimize-images.ts'] },
},
]
function transcript(): ChatMessage[] {
return [
{ id: 'user-0', role: 'user', parts: [{ type: 'text', text: QUESTION }] },
{
id: 'assistant-0',
role: 'assistant',
parts: [
...SCRIPT.map((step, index) => ({
...step,
toolCallId: `call-0-${index}`,
state: 'output-available',
}) as StepPart),
{ type: 'text', text: ANSWER },
],
},
]
}
const messages = ref<ChatMessage[]>(transcript())
const status = ref<'ready' | 'submitted' | 'streaming'>('ready')
const busy = computed(() => status.value !== 'ready')
// The open state of each summary, once the person opens or closes it.
const openState = ref<Record<string, boolean | undefined>>({})
// 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 getSteps(message: ChatMessage) {
return message.parts.filter((part): part is StepPart => part.type !== 'text')
}
function isStreaming(message: ChatMessage) {
return busy.value && message === messages.value.at(-1)
}
function getSummary(message: ChatMessage) {
const steps = getSteps(message)
const running = isStreaming(message) && getText(message).length === 0
const failed = steps.filter(part => part.state === 'output-error').length
const status: Status = running ? 'running' : failed > 0 ? 'error' : undefined
const title = running
? 'Researching'
: `Researched in ${steps.length} steps${failed > 0 ? `, ${failed} failed` : ''}`
return { running, status, title }
}
function getStepStatus(part: StepPart, streaming: boolean): Status {
if (part.state === 'output-available')
return undefined
return part.state === 'output-error' || !streaming ? 'error' : 'running'
}
function getStepTitle(part: StepPart, streaming: boolean) {
const status = getStepStatus(part, streaming)
const verb = (running: string, done: string, failed: string) =>
status === 'running' ? running : status === 'error' ? failed : done
if (part.type === 'tool-searchWeb')
return `${verb('Searching', 'Searched', 'Failed to search')} the web for ${part.input.query}`
if (part.type === 'tool-readBuildLog')
return `${verb('Reading', 'Read', 'Failed to read')} the build log for ${part.input.deployment}`
return `${verb('Searching', 'Searched', 'Failed to search')} the code for ${part.input.query}`
}
async function replay() {
const id = ++run
messages.value = [{ id: `user-${id}`, role: 'user', parts: [{ type: 'text', text: QUESTION }] }]
openState.value = {}
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'
for (const [index, step] of SCRIPT.entries()) {
const { output, ...rest } = step
const part = reactive({
...rest,
toolCallId: `call-${id}-${index}`,
state: 'input-available',
} as StepPart)
message.parts.push(part)
if (!(await sleep(900)))
return
part.output = output as never
part.state = 'output-available'
}
const text = reactive<TextPart>({ type: 'text', text: '' })
message.parts.push(text)
if (!(await streamInto(text, ANSWER, 20)))
return
status.value = 'ready'
}
const [DefineLine, ReuseLine] = createReusableTemplate<{
title: string
status: Status
}>()
</script>
<template>
<DefineLine v-slot="{ title, status: lineStatus }">
<TraceLine :status="lineStatus">
<TraceIcon>
<LoaderCircleIcon v-if="lineStatus === 'running'" class="animate-spin" />
<CircleAlertIcon v-else-if="lineStatus === 'error'" />
<CircleCheckIcon v-else />
</TraceIcon>
<TraceTitle>{{ title }}</TraceTitle>
</TraceLine>
</DefineLine>
<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="getSteps(message).length > 0">
<TraceCollapsible
:open="openState[message.id] ?? getSummary(message).running"
:unmount-on-hide="false"
@update:open="openState[message.id] = $event"
>
<TraceCollapsibleTrigger>
<ReuseLine
:title="getSummary(message).title"
:status="getSummary(message).status"
/>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<template v-for="part in getSteps(message)" :key="part.toolCallId">
<ReuseLine
v-if="part.state === 'input-available'"
:title="getStepTitle(part, isStreaming(message))"
:status="getStepStatus(part, isStreaming(message))"
/>
<TraceCollapsible
v-else
:default-open="part.type === 'tool-readBuildLog'"
>
<TraceCollapsibleTrigger>
<ReuseLine
:title="getStepTitle(part, isStreaming(message))"
:status="getStepStatus(part, isStreaming(message))"
/>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock v-if="part.state === 'output-error'" size="sm" variant="destructive">
<CodeBlockContent wrap class="max-h-64">
{{ part.errorText }}
</CodeBlockContent>
</CodeBlock>
<Trace v-else-if="part.type === 'tool-searchWeb'">
<a
v-for="result in part.output?.results"
:key="result.url"
:href="result.url"
target="_blank"
rel="noreferrer"
>
<TraceLine>
<TraceIcon>
<GlobeIcon />
</TraceIcon>
<TraceTitle>
{{ result.title }}
<span class="sr-only"> (opens in a new tab)</span>
</TraceTitle>
<TraceIcon>
<ArrowUpRightIcon />
</TraceIcon>
</TraceLine>
</a>
</Trace>
<CodeBlock v-else-if="part.type === 'tool-readBuildLog'" size="sm">
<CodeBlockContent wrap class="max-h-64">
{{ part.output?.log }}
</CodeBlockContent>
</CodeBlock>
<div v-else class="flex flex-wrap gap-1.5">
<Badge
v-for="file in part.output?.files"
:key="file"
variant="secondary"
class="font-mono"
>
{{ file }}
</Badge>
</div>
</TraceCollapsibleContent>
</TraceCollapsible>
</template>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</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>Chain of Thought is a summary line that expands into the steps an agent took. The summary says what happened, like "Researched in 3 steps". The steps sit inside it, each one a line of its own.
Chain of Thought is built with Trace with a TraceCollapsible for the summary.
Installation
Chain of Thought is built with the Trace, Code Block and Badge components.
pnpm dlx shadcn-vue@latest add trace code-block badge
Usage
<script setup lang="ts">
import { CircleCheckIcon } from '@lucide/vue'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
</script>
<template>
<Trace>
<TraceCollapsible default-open :unmount-on-hide="false">
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<CircleCheckIcon />
</TraceIcon>
<TraceTitle>Researched in 3 steps</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<TraceLine>
<TraceIcon>
<CircleCheckIcon />
</TraceIcon>
<TraceTitle>Read the build log for dpl_8Kx2</TraceTitle>
</TraceLine>
<TraceLine>
<TraceIcon>
<CircleCheckIcon />
</TraceIcon>
<TraceTitle>Searched the web for next.js image cache key</TraceTitle>
</TraceLine>
<TraceLine>
<TraceIcon>
<CircleCheckIcon />
</TraceIcon>
<TraceTitle>Searched the code for cacheKey</TraceTitle>
</TraceLine>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</template>Steps
Each step is a TraceLine. Set its status and write the title in the matching tense.
| Step | status | Title |
|---|---|---|
| Running | "running" | Searching for … |
| Done, with detail | none | Searched for … |
| Done, links out | none | Read the Image component docs |
| Failed | "error" | Failed to search for … |
Set :unmount-on-hide="false" on the summary's TraceCollapsible. Steps keep their open state when the summary closes.
Search Results
When a step runs a web search, show the results as a Badge for each hostname in the step's content.
<script setup lang="ts">
function getHostname(url: string) {
return new URL(url).hostname
}
</script>
<template>
<TraceCollapsibleContent class="flex flex-wrap gap-1.5">
<Badge v-for="result in results" :key="result.url" variant="secondary">
{{ getHostname(result.url) }}
</Badge>
</TraceCollapsibleContent>
</template>AI SDK
Render each tool part as a step. running is true while tool parts stream and before the reply starts. Set the summary's status and title from the steps. The summary opens while the steps run, as in Trace.
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
steps: StepPart[]
streaming: boolean
answering: boolean
}>()
const open = ref<boolean>()
const running = computed(() => props.streaming && !props.answering)
const failed = computed(
() => props.steps.filter(part => part.state === 'output-error').length,
)
</script>
<template>
<TraceCollapsible
:open="open ?? running"
:unmount-on-hide="false"
@update:open="open = $event"
>
<TraceCollapsibleTrigger>
<TraceLine :status="running ? 'running' : failed > 0 ? 'error' : undefined">
<TraceTitle>
{{ running ? 'Researching' : `Researched in ${steps.length} steps` }}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
<StepLine
v-for="part in steps"
:key="part.toolCallId"
:part="part"
:streaming="streaming"
/>
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</template>StepLine renders one tool part as a TraceLine, as in Tool.
Migrating from AI Elements Vue
If you added ChainOfThought from AI Elements Vue, your project has a components/ai-elements/chain-of-thought/ folder. Paste this prompt into your coding agent to rebuild it on Trace and Badge. Every export keeps its name and props, so the code that uses it keeps working.
Migrate components/ai-elements/chain-of-thought/ from AI Elements Vue to the shadcn-vue Trace and Badge components.
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 the components: npx shadcn-vue@latest add trace badge
2. Read https://www.shadcn-vue.com/docs/ai-elements/chain-of-thought and build Chain of Thought the same way.
3. Rebuild each part:
- ChainOfThought.vue renders a Trace with a TraceCollapsible. Pass open, defaultOpen and the update:open emit to the TraceCollapsible.
- ChainOfThoughtHeader.vue renders a TraceCollapsibleTrigger with a TraceLine, a TraceIcon and a TraceTitle, like "Researched in 3 steps".
- ChainOfThoughtContent.vue renders a TraceCollapsibleContent with a Trace for the steps. Set :unmount-on-hide="false" on the TraceCollapsible so the steps stay mounted.
- ChainOfThoughtStep.vue renders a TraceLine. Its icon goes in a TraceIcon, its label in a TraceTitle and its description in a TraceDescription. Map status "active" to "running", "pending" to "pending", and "complete" to no status.
- ChainOfThoughtSearchResults.vue renders a flex-wrap list, and ChainOfThoughtSearchResult.vue a Badge with variant="secondary".
- Keep ChainOfThoughtImage.vue as it is.
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