AI Messages
The transcript: a scrolling list of turns with per-role defaults already set.
It wraps Nuxt UI's UChatMessages and does one thing the wrapper cannot — it
accepts this module's six-state AIState vocabulary and translates it down to
the four states chat components actually speak.
That translation is the whole reason it exists. idle, listening, thinking,
streaming, done, error describe what an AI surface is doing far better
than ready / submitted / streaming / error describe what a chat list
should show. Rather than asking every consumer to bridge the two, this component
collapses the six onto the four in one place:
state | chat status | why |
|---|---|---|
idle | ready | resting, input accepted |
listening | ready | still taking input in, so it is still ready |
thinking | submitted | a turn is in flight, nothing has arrived |
streaming | streaming | tokens are landing |
done | ready | finished, back to a resting surface |
error | error | the turn failed |
Usage
Pass your turns as messages with parts and set state to whichever of the six
AI states the surface is in — you never pick a chat status yourself. To render a
single turn with the motion-choreographed action row, see
AI Message.
<template>
<div class="flex w-full max-w-lg flex-col gap-4">
<div class="flex items-center gap-2">
<UButton
v-for="value in states"
:key="value"
:color="state === value ? 'primary' : 'neutral'"
:label="value"
:variant="state === value ? 'solid' : 'outline'"
size="sm"
@click="state = value"
/>
</div>
<div class="rounded-2xl border border-default">
<MAiMessages
:messages="messages"
:state="state"
class="max-h-72"
>
<template #leading="{ role }">
<MAgentAvatar
v-if="role !== 'user'"
:seed="role"
:size="28"
/>
</template>
</MAiMessages>
</div>
</div>
</template>
<script setup lang="ts">
type ChatMessage = {
id: string
role: 'system' | 'user' | 'assistant'
parts: { type: string, text?: string }[]
metadata?: unknown
}
type DemoState = 'idle' | 'listening' | 'thinking' | 'streaming' | 'done' | 'error'
const states: DemoState[] = ['idle', 'thinking', 'streaming', 'done', 'error']
const state = ref<DemoState>('idle')
const messages = ref<ChatMessage[]>([
{
id: 'm-1',
role: 'user',
parts: [{ type: 'text', text: 'Why does the token audit need a flag?' }],
},
{
id: 'm-2',
role: 'assistant',
parts: [{
type: 'text',
text: 'It walks every theme token, so landing it cold produces a long tail of visual diffs that nobody has reviewed yet.',
}],
},
])
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
messages | AiUIMessage[] | - | Required. Each entry is { id, role, parts, metadata? }, where role is
'system' | 'user' | 'assistant' and parts is an array of
{ type, state?, text? }. type is a plain string, so a part type the AI SDK
adds later cannot break your build. |
state | 'idle' | 'listening' | 'thinking' | 'streaming' | 'done' | 'error' | - | The conversation's state in this module's vocabulary, mapped internally onto a chat status per the table above. Omit it for a settled conversation. |
user | Record<string, unknown> | - | Extra props for the user role, merged over the defaults
{ side: 'right', variant: 'solid', color: 'neutral' }. |
assistant | Record<string, unknown> | - | Extra props for the assistant role, merged over the defaults
{ side: 'left', variant: 'soft' }. Assistant turns stay soft rather than
solid so a turn carrying its own surfaces — a reasoning trace, a tool call — is
not boxed twice. |
compact | boolean | false | Tighter vertical rhythm, passed through to the wrapper. |
shouldAutoScroll | boolean | false | Track new content automatically. |
shouldScrollToBottom | boolean | true | Jump to the newest turn when the list mounts. |
spacingOffset | number | 0 | Extra scroll room at the end of the list, in pixels. Raise it when the prompt input sits above the transcript rather than below it. |
class | string | '' | Merged onto the wrapper. |
Slots
The wrapper forwards every slot it does not consume — header, leading,
files, body, content, actions — into each turn. Slot props come from
UChatMessages, so a #leading receives the turn's role, and a #body
receives the turn's parts. A #default slot is forwarded too, which is the
supported way to draw the list yourself.
To get the motion-choreographed action row and the per-role bubble defaults on
each turn, use AI Message inside a #default
slot instead of relying on the wrapper's own rendering.
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiMessages in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - |
Accessibility
- This component adds no ARIA of its own; the scroll container, landmarks and
live-region behaviour come from
UChatMessages. stateis the hook a screen reader can be told about. Nuxt UI announces status changes off the mapped chat status, which is whythinkingandstreamingmap tosubmittedandstreamingand not toready.- Because
listeninganddoneboth collapse toready, neither announces a change to assistive technology. If your UI needs those distinctions spoken, render them yourself rather than assuming the transcript will. - The list has no fixed height by default. Set one on the wrapper —
max-h-72in the example above — so scrolling actually happens, and keep the scroll container focusable if you want keyboard paging.
Performance
stateis resolved through acomputed, so a per-frame state change never re-renders the list; only the computed status string changes, and the wrapper receives a differentstatusprop.- The forwarded-slot list is derived from
useSlots()on each render, so adding a slot is picked up without a restart. - Role defaults are declared
as const. An object literal inside acomputedwidensside,variantandcolortostring, which no longer satisfies the unionsChatMessageexpects — a type error that only appears once the merge is written naively. userandassistantare spread into fresh objects per change, so mutating a role's props does not leak into the other role's defaults.