- 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
The agent can read your code, run commands and make changes. Press send to start.
<script setup lang="ts">
import {
ArrowUpIcon,
CircleCheckIcon,
GlobeIcon,
LoaderCircleIcon,
MessageCircleDashedIcon,
PaperclipIcon,
PlusIcon,
RotateCwIcon,
TelescopeIcon,
TerminalIcon,
} from '@lucide/vue'
import { createReusableTemplate, promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import Markdown from '@/components/Markdown.vue'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '@/components/ui/input-group'
import { Message, MessageContent } from '@/components/ui/message'
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from '@/components/ui/trace'
// The parts of an agent turn, shaped like AI SDK UI message parts.
type ChatPart
= | { type: 'reasoning', text: string, state: 'streaming' | 'done' }
| {
type: 'tool-runCommand'
state: 'input-available' | 'output-available' | 'output-error'
input: { command: string }
output?: { stdout: string }
errorText?: string
}
| { type: 'text', text: string }
interface ChatMessage {
id: string
role: 'user' | 'assistant'
parts: ChatPart[]
}
// A scripted agent turn. Each step streams into the message in order.
type Step
= | { type: 'reasoning', text: string }
| { type: 'sleep', ms: number }
| { type: 'tool', command: string, ms: number, stdout: string }
| { type: 'text', text: string }
const script: { user: string, steps: Step[] }[] = [
{
user: 'Add a dark mode toggle to the settings page.',
steps: [
{
type: 'reasoning',
text: 'I need to know how themes are set up before I add a toggle. If @nuxtjs/color-mode is already installed, the toggle only has to set colorMode.preference.',
},
{ type: 'sleep', ms: 400 },
{
type: 'tool',
command: 'rg -l colorMode nuxt.config.ts app',
ms: 800,
stdout: 'nuxt.config.ts',
},
{ type: 'sleep', ms: 500 },
{
type: 'text',
text: `@nuxtjs/color-mode is already set up in \`nuxt.config.ts\`, so the toggle only has to set \`colorMode.preference\`.
**Changes**
- Added \`components/ModeToggle.vue\` with a Light and Dark option.
- Put \`<ModeToggle />\` next to the Appearance heading in \`pages/settings.vue\`.
\`\`\`ts
const colorMode = useColorMode()
\`\`\`
`,
},
],
},
{
user: 'Make it follow the system setting by default.',
steps: [
{
type: 'reasoning',
text: 'The default theme lives in the colorMode options. I should check what it is set to now.',
},
{ type: 'sleep', ms: 400 },
{
type: 'tool',
command: 'rg -n preference nuxt.config.ts',
ms: 800,
stdout: '12: preference: \'light\',',
},
{ type: 'sleep', ms: 500 },
{
type: 'text',
text: `It was set to \`light\`. I updated the \`colorMode\` options in \`nuxt.config.ts\`:
\`\`\`ts
colorMode: { classSuffix: '', preference: 'system' },
\`\`\`
New visitors now get the theme from their OS. The toggle also has a **System** option, so anyone who picked Light or Dark can switch back.
`,
},
],
},
{
user: 'Run the build before I ship it.',
steps: [
{
type: 'tool',
command: 'pnpm build',
ms: 1600,
stdout:
'Nuxt 4.0.0 with Nitro 2.12.0\n\n✓ Client built in 4.2s\n✓ Server built in 1.8s\n✓ Prerendered 12 routes',
},
{ type: 'sleep', ms: 500 },
{
type: 'text',
text: `The build passes, so it is ready to ship.
- Built the client in **4.2s**.
- No build or type errors.
- Prerendered all **12** routes.
`,
},
],
},
]
const fallback = 'This demo only knows the scripted turns. Press reset to start over.'
const messages = ref<ChatMessage[]>([])
const status = ref<'ready' | 'submitted' | 'streaming'>('ready')
const draft = ref<string | null>(null)
const formRef = ref<HTMLFormElement | null>(null)
// Open state of each reasoning row, set once the person toggles it.
const reasoningOpen = reactive<Record<string, boolean | undefined>>({})
const busy = computed(() => status.value !== 'ready')
const sentCount = computed(() => messages.value.filter(message => message.role === 'user').length)
const nextMessage = computed(() => script[sentCount.value]?.user ?? null)
const text = computed({
get: () => draft.value ?? nextMessage.value ?? '',
set: (value: string) => {
draft.value = value
},
})
const canSend = computed(() => text.value.trim().length > 0 && !busy.value)
// Simulated streaming. Reset and unmount bump `run`, so a running script
// stops at its next wait.
let run = 0
tryOnScopeDispose(() => run++)
async function sleep(ms: number, id: number) {
await promiseTimeout(ms)
return id === run
}
async function stream(value: string, write: (text: string) => void, id: number) {
for (let cursor = 0; cursor < value.length;) {
cursor = Math.min(value.length, cursor + 3)
write(value.slice(0, cursor))
if (!(await sleep(16, id)))
return false
}
return true
}
async function respond(steps: Step[]) {
const id = run
status.value = 'submitted'
if (!(await sleep(600, id)))
return
messages.value.push({ id: `assistant-${Date.now()}`, role: 'assistant', parts: [] })
const message = messages.value.at(-1)!
status.value = 'streaming'
for (const step of steps) {
if (step.type === 'sleep') {
if (!(await sleep(step.ms, id)))
return
}
else if (step.type === 'reasoning') {
message.parts.push({ type: 'reasoning', text: '', state: 'streaming' })
const part = message.parts.at(-1) as Extract<ChatPart, { type: 'reasoning' }>
if (!(await stream(step.text, (value) => {
part.text = value
}, id))) {
return
}
part.state = 'done'
}
else if (step.type === 'tool') {
message.parts.push({
type: 'tool-runCommand',
state: 'input-available',
input: { command: step.command },
})
const part = message.parts.at(-1) as Extract<ChatPart, { type: 'tool-runCommand' }>
if (!(await sleep(step.ms, id)))
return
part.output = { stdout: step.stdout }
part.state = 'output-available'
}
else {
message.parts.push({ type: 'text', text: '' })
const part = message.parts.at(-1) as Extract<ChatPart, { type: 'text' }>
if (!(await stream(step.text, (value) => {
part.text = value
}, id))) {
return
}
}
}
status.value = 'ready'
}
function onSubmit() {
if (!canSend.value)
return
const turn = draft.value === null ? script[sentCount.value] : undefined
messages.value.push({
id: `user-${Date.now()}`,
role: 'user',
parts: [{ type: 'text', text: text.value.trim() }],
})
draft.value = null
respond(turn?.steps ?? [{ type: 'text', text: fallback }])
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
formRef.value?.requestSubmit()
}
}
function reset() {
run++
messages.value = []
draft.value = null
status.value = 'ready'
for (const key in reasoningOpen)
delete reasoningOpen[key]
}
function getMessageText(message: ChatMessage) {
return message.parts
.map(part => (part.type === 'text' ? part.text : ''))
.join('')
}
type ToolPart = Extract<ChatPart, { type: 'tool-runCommand' }>
const [DefineReasoningRow, ReasoningRow] = createReusableTemplate<{
id: string
text: string
running: boolean
}>()
const [DefineToolRow, ToolRow] = createReusableTemplate<{
part: ToolPart
streaming: boolean
}>()
function getSteps(message: ChatMessage) {
return message.parts.filter(
part => part.type === 'reasoning' || part.type === 'tool-runCommand',
) as Exclude<ChatPart, { type: 'text' }>[]
}
</script>
<template>
<DefineReasoningRow v-slot="{ id, text: reasoning, running }">
<TraceCollapsible
:open="reasoningOpen[id] ?? running"
@update:open="reasoningOpen[id] = $event"
>
<TraceCollapsibleTrigger>
<TraceLine :status="running ? 'running' : undefined">
<TraceIcon>
<LoaderCircleIcon v-if="running" class="animate-spin" />
<CircleCheckIcon v-else />
</TraceIcon>
<TraceTitle>{{ running ? 'Thinking' : 'Thought' }}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent class="whitespace-pre-wrap">
{{ reasoning }}
</TraceCollapsibleContent>
</TraceCollapsible>
</DefineReasoningRow>
<DefineToolRow v-slot="{ part, streaming }">
<TraceCollapsible
v-if="part.state === 'output-available' || part.state === 'output-error'"
>
<TraceCollapsibleTrigger>
<TraceLine :status="part.state === 'output-error' ? 'error' : undefined">
<TraceIcon>
<TerminalIcon />
</TraceIcon>
<TraceTitle>
{{ part.state === 'output-error' ? 'Failed to run' : 'Ran' }} {{ part.input.command }}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap class="max-h-64">
{{ part.state === 'output-error' ? part.errorText : part.output?.stdout }}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
<TraceLine v-else :status="streaming ? 'running' : 'error'">
<TraceIcon>
<TerminalIcon />
</TraceIcon>
<TraceTitle>
{{ streaming ? 'Running' : 'Failed to run' }} {{ part.input.command }}
</TraceTitle>
</TraceLine>
</DefineToolRow>
<MessageScrollerProvider>
<div class="relative flex w-full flex-col gap-4 [[data-slot=preview]:has(&)]:bg-background [[data-slot=preview]:has(&)>div]:p-0!">
<div class="flex h-200 w-full flex-col gap-4">
<div class="flex items-start justify-between gap-4 p-8 pb-4">
<div class="flex flex-col gap-1">
<div class="font-medium">
New Thread
</div>
<div class="text-sm text-muted-foreground">
Ask the agent to change your code.
</div>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
aria-label="Reset conversation"
:disabled="busy"
@click="reset"
>
<RotateCwIcon />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Reset</p>
</TooltipContent>
</Tooltip>
</div>
<div class="min-h-0 flex-1">
<Empty v-if="messages.length === 0" class="h-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircleDashedIcon />
</EmptyMedia>
<EmptyTitle>What should we build?</EmptyTitle>
<EmptyDescription>
The agent can read your code, run commands and make changes.
Press send to start.
</EmptyDescription>
</EmptyHeader>
</Empty>
<MessageScroller v-else>
<MessageScrollerViewport>
<MessageScrollerContent :aria-busy="busy" class="gap-6 px-8 py-0">
<template v-for="message in messages" :key="message.id">
<MessageScrollerItem
v-if="message.role === 'user'"
:message-id="message.id"
scroll-anchor
class="ms-anim-slide-up"
>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ getMessageText(message) }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
<MessageScrollerItem v-else :message-id="message.id">
<Message>
<MessageContent class="gap-3">
<Trace v-if="getSteps(message).length > 0">
<template
v-for="(part, index) in getSteps(message)"
:key="index"
>
<ReasoningRow
v-if="part.type === 'reasoning'"
:id="`${message.id}-${index}`"
:text="part.text"
:running="part.state === 'streaming'"
/>
<ToolRow
v-else
:part="part"
:streaming="busy && message === messages.at(-1)"
/>
</template>
</Trace>
<Markdown
v-if="getMessageText(message)"
:value="getMessageText(message)"
:streaming="busy && message === messages.at(-1)"
/>
</MessageContent>
</Message>
</MessageScrollerItem>
</template>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</div>
<div class="p-8 pt-0">
<form ref="formRef" class="w-full" @submit.prevent="onSubmit">
<InputGroup>
<InputGroupTextarea
v-model="text"
placeholder="Ask the agent..."
aria-label="Message"
class="min-h-14"
@keydown="onKeydown"
/>
<InputGroupAddon align="block-end" class="pt-1">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<InputGroupButton
aria-label="Add context"
type="button"
size="icon-sm"
variant="outline"
>
<PlusIcon />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" class="w-44">
<DropdownMenuItem>
<PaperclipIcon />
Add Photos & Files
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<TelescopeIcon />
Deep Research
</DropdownMenuItem>
<DropdownMenuItem>
<GlobeIcon />
Web Search
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<InputGroupButton
type="submit"
variant="default"
size="icon-sm"
:disabled="!canSend"
class="ml-auto"
>
<ArrowUpIcon />
<span class="sr-only">Send</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</div>
</div>
</div>
</MessageScrollerProvider>
</template>Chat is the thread of a conversation with the agent. Each turn is a Message inside Message Scroller. The person's message is the scroll anchor, so a new question settles near the top and the reply streams in under it. The field is a Prompt Input.
Installation
Chat is built with the Message Scroller, Message, Bubble, and Input Group components.
pnpm dlx shadcn-vue@latest add message-scroller message bubble input-group
Usage
<template>
<MessageScrollerProvider auto-scroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem :message-id="id" scroll-anchor>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{{ text }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</template>scroll-anchor goes on the person's turn. auto-scroll follows the reply while the person stays at the live edge.
See Message Scroller.
AI SDK
Render chat.messages from the AI SDK Chat class. Each message is a MessageScrollerItem keyed by its id, and the person's messages are the scroll anchors.
<script setup lang="ts">
import { Chat } from '@ai-sdk/vue'
const chat = new Chat({})
</script>
<template>
<MessageScrollerProvider auto-scroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent :aria-busy="chat.status === 'streaming'">
<MessageScrollerItem
v-for="message in chat.messages"
:key="message.id"
:message-id="message.id"
:scroll-anchor="message.role === 'user'"
>
<!-- Render the message -->
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</template>Markdown
The agent's reply streams as markdown. Render it with Comark (@comark/vue) and route fenced code to a Code Block with a custom pre component, the same way as in Message. Pass streaming only to the reply that is still streaming.
<script setup lang="ts">
import { Markdown } from '@comark/vue'
import MarkdownCodeBlock from './MarkdownCodeBlock.vue'
</script>
<template>
<Message>
<MessageContent>
<Markdown
class="typeset"
:value="getText(message)"
:components="{ pre: MarkdownCodeBlock }"
:streaming="chat.status === 'streaming' && message === chat.messages.at(-1)"
/>
</MessageContent>
</Message>
</template>Migrating from AI Elements Vue
If you added Conversation from AI Elements Vue, your project has a components/ai-elements/conversation/ folder. Paste this prompt into your coding agent to rebuild it on Message Scroller. Every export keeps its name and props, so the code that uses it keeps working.
Migrate components/ai-elements/conversation/ from AI Elements Vue to the shadcn-vue Message Scroller component.
Keep every export, with the same name and props, so the code that imports it keeps working. Only change how each part renders.
1. Install Message Scroller: npx shadcn-vue@latest add message-scroller
2. Read https://www.shadcn-vue.com/docs/ai-elements/chat and build Chat the same way.
3. Rebuild each part:
- Conversation renders a MessageScrollerProvider with auto-scroll and a MessageScroller, instead of StickToBottom.
- ConversationContent renders a MessageScrollerViewport and a MessageScrollerContent.
- ConversationScrollButton renders a MessageScrollerButton.
- Keep ConversationEmptyState, ConversationDownload and messagesToMarkdown as they are.
4. In the code that renders the thread, wrap each message in a MessageScrollerItem with its message-id, and set scroll-anchor on the person's messages. This is the only change outside the folder.
5. Remove vue-stick-to-bottom from the dependencies.
6. Remove the imports the files no longer use.
7. Run vue-tsc and fix any errors.Sponsors
Your logo hereshadcn-vue is free and open source. Sponsorship keeps it maintained.
Become a sponsor