Conversation
A whole chat thread, put together from the other conversation parts. Pass messages and the status of the reply, and it groups messages by sender, separates days, follows the latest message, shows tools and attachments, opens images in a lightbox, and offers copy, regenerate, edit and feedback wherever you pass a handler. A finished reply is read to screen readers once, and nothing is read while it streams; what is read is the message text, so keep it plain. It fetches nothing: what the user does is reported through the callbacks.
Roles
A user message is a bubble on the right, an assistant message sits on the left with its avatar and name, and a system message is a quiet line in the middle. MessageBubble takes the content as children and leaves the time and the actions to snippets.
<div class="surface-sheen w-full max-w-xl space-y-4 rounded-lg p-4">
<MessageBubble role="system">The assistant joined the conversation</MessageBubble>
<MessageBubble role="user">
Does the starter kit work with any lamp?
{#snippet meta()}<MessageTimestamp date={ago(5)} />{/snippet}
</MessageBubble>
<MessageBubble role="assistant" name="Madam Glam">
It needs a UV or LED lamp that cures at 365 to 405 nm. Most lamps sold for gel
polish do.
{#snippet meta()}<MessageTimestamp date={ago(4)} />{/snippet}
</MessageBubble>
</div>Grouped and single
Messages from the same sender in a row share one avatar and name, and a time shows once under the last of them. A sender who comes back after someone else starts a new group.
<div class="surface-sheen flex h-80 w-full max-w-xl flex-col rounded-lg">
<Conversation messages={grouped} assistant={person} />
</div>Long text and code
Text keeps its line breaks, and long lines wrap inside the message. Pass a content snippet to render a message your own way, such as Markdown.
<div class="surface-sheen flex h-80 w-full max-w-xl flex-col rounded-lg">
<Conversation messages={long} assistant={person}>
{#snippet content(message)}
{#each message.text.split('\n\n') as block (block)}
{#if block.startsWith('const')}
<pre
class="bg-field surface-field overflow-x-auto rounded-md p-3 text-xs">{block}</pre>
{:else}
<p>{block}</p>
{/if}
{/each}
{/snippet}
</Conversation>
</div>Attachments
MessageAttachments lays out a message's files: an image with a url as a thumbnail, any other file as a chip with its name and size that opens in a new tab. Its images open in one Lightbox that pages through every image of the message. Conversation renders one per message; reach for it on its own when you compose bubbles by hand.
<div class="space-y-6">
<MessageAttachments
attachments={[
PHOTOS[2],
PHOTOS[0],
{ id: 'b', name: 'receipt.pdf', type: 'application/pdf', size: 184320 },
{
id: 'c',
name: 'application-guide.pdf',
type: 'application/pdf',
size: 2411724,
url: '#'
}
]}
/>
<div class="surface-sheen flex h-96 w-full max-w-xl flex-col rounded-lg">
<Conversation messages={attachments} assistant={person} />
</div>
</div>Queued messages
A message sent while a reply is still coming shows as queued, under the bubble, until it goes out.
<div class="surface-sheen flex h-72 w-full max-w-xl flex-col rounded-lg">
<Conversation messages={queued} assistant={person} />
</div>Streaming
status drives the thread. submitted shows the typing indicator, streaming lets the reply grow in place and holds back its actions, and ready brings them back. A screen reader hears nothing while the reply streams, then the finished reply once as plain text.
<div class="w-full max-w-xl space-y-3">
<div class="surface-sheen flex h-96 flex-col rounded-lg">
<Conversation messages={streamed} status={streamStatus} assistant={person} />
</div>
<Button onclick={ask} disabled={streamStatus !== 'ready'}>Ask a question</Button>
</div>Error and retry
An error shows after the last message with a retry button. The thread keeps everything that was said, so the shopper can see what failed.
<div class="surface-sheen flex h-72 w-full max-w-xl flex-col rounded-lg">
<Conversation
messages={queued.slice(0, 1)}
status={failed ? 'error' : retrying ? 'submitted' : 'ready'}
error={failed ? 'We could not reach the assistant. Try again.' : undefined}
onRetry={retry}
assistant={person}
/>
</div>Tool states
A tool the assistant ran shows its state and how long it took, and expands to the arguments and the result or the error. Show a friendly label: the internal tool name appears only when there is no label.
Arguments
{
"query": "gel starter kit",
"limit": 3
}Arguments
{
"query": "gel starter kit",
"limit": 3
}Result
{
"found": 3,
"top": "At-Home Gel Starter Kit"
}The inventory service did not answer in time.
<div class="w-full max-w-xl space-y-2">
{#each tools as tool (tool.id)}
<ToolExecution
name={tool.name}
label={tool.label}
state={tool.state}
duration={tool.duration}
input={tool.input}
output={tool.output}
error={tool.error}
open={tool.id === 't3'}
/>
{/each}
</div>Several tools in one reply
A reply can carry any number of tools. They sit above its text, in the order they ran.
<div class="surface-sheen flex h-80 w-full max-w-xl flex-col rounded-lg">
<Conversation messages={severalTools} assistant={person} />
</div>Several days
A separator marks each new day, labelled Today, Yesterday or the date in the reader's locale. Runs of messages never carry across midnight.
<div class="space-y-6">
<DateSeparator date={ago(60 * 52)} class="w-full max-w-xl" />
<div class="surface-sheen flex h-96 w-full max-w-xl flex-col rounded-lg">
<Conversation messages={severalDays} assistant={person} />
</div>
</div>Message actions
Copy is always there. Regenerate, edit and feedback appear where you pass their handler, and items adds a menu for the rest. The actions are one tab stop, and the arrow keys move between them. In the thread they show when the pointer or keyboard focus is on the message.
Feedback: none. Last action: none.
<div class="space-y-4">
<MessageActions
role="assistant"
text="It needs a UV or LED lamp."
{feedback}
onRegenerate={() => (copied = 'Regenerate')}
onFeedback={(value) => (feedback = value)}
items={[
{ label: 'Report a problem', onSelect: () => (copied = 'Report') },
{ separator: true },
{ label: 'Delete', destructive: true, onSelect: () => (copied = 'Delete') }
]}
/>
<MessageActions
role="user"
text="Does it ship to Canada?"
onEdit={() => (copied = 'Edit')}
/>
<p class="text-muted text-xs">
Feedback: {feedback ?? 'none'}. Last action: {copied ?? 'none'}.
</p>
</div>Empty and loading
With no messages the thread shows a line of text, or whatever you pass as empty. While loading, it shows placeholders shaped like messages, so nothing jumps when they arrive.
Ask me anything
About sizes, shipping or your order.
<div class="grid w-full max-w-3xl gap-4 md:grid-cols-2">
<div class="surface-sheen flex h-64 flex-col rounded-lg">
<Conversation messages={[]}>
{#snippet empty()}
<div class="py-12 text-center">
<p class="font-medium">Ask me anything</p>
<p class="text-muted text-sm">About sizes, shipping or your order.</p>
</div>
{/snippet}
</Conversation>
</div>
<div class="surface-sheen flex h-64 flex-col rounded-lg">
<Conversation messages={[]} loading />
</div>
</div>Jump to latest
The thread follows new messages while you are at the bottom. Scroll up to read, then add a message: it stays where you are and offers a button to go back.
<div class="w-full max-w-xl space-y-3">
<div class="surface-sheen flex h-72 flex-col rounded-lg">
<Conversation messages={chatty} assistant={person} />
</div>
<Button variant="secondary" onclick={addMessage}
><Plus class="size-4" />Add a message</Button
>
</div>Mobile width
The thread holds its shape at 375 px: bubbles wrap, and the tool rows truncate instead of overflowing.
<div class="surface-sheen flex h-96 w-93.75 max-w-full flex-col rounded-lg">
<Conversation messages={mobile} assistant={person} />
</div>Header
The bar at the top of a conversation: a title, an optional line under it such as the model in use, and your own controls on either side.
Madam Glam
Assistant · Fast model
<div class="surface-sheen w-full max-w-xl rounded-lg">
<ConversationHeader title="Madam Glam" description="Assistant · Fast model">
{#snippet actions()}
<Button variant="ghost"><Plus class="size-4" />New chat</Button>
{/snippet}
</ConversationHeader>
<div class="p-4"><TypingIndicator /></div>
</div>API
Conversation
A whole chat thread, put together from the other conversation parts. Pass messages and the status of the reply, and it groups messages by sender, separates days, follows the latest message, shows tools and attachments, opens images in a lightbox, and offers copy, regenerate, edit and feedback wherever you pass a handler. A finished reply is read to screen readers once, and nothing is read while it streams; what is read is the message text, so keep it plain. It fetches nothing: what the user does is reported through the callbacks.
| Prop | Type | Default | Description |
|---|---|---|---|
messages required | ConversationMessage[] | The thread, oldest first. | |
status | 'ready'|'submitted'|'streaming'|'error' | 'ready' | Where the reply is. |
loading | boolean | false | Shows placeholders in place of the messages. |
error | string | What went wrong. Shown after the last message with a retry button. | |
onRetry | () => void | Called when retry is pressed. | |
assistant | ConversationPerson | { name: 'Assistant' } | Who the assistant is. |
onRegenerate | (message: ConversationMessage) => void | Shows Regenerate on the latest assistant reply. | |
onEdit | (message: ConversationMessage) => void | Shows Edit on user messages. | |
onFeedback | (message: ConversationMessage, feedback: 'up'|'down'|null) => void | Shows thumbs up and down on assistant replies. | |
menuItems | import('./conversation-message-actions.svelte').MessageActionItem[] | [] | Extra actions for every message, in a menu. |
empty | import('svelte').Snippet | What to show when there are no messages. | |
content | import('svelte').Snippet<[ConversationMessage]> | Renders a message's text, for example as Markdown. Defaults to the plain text. | |
class | string | Additional Tailwind classes merged onto the viewport, which fills the space left in a flex column: give that column a height. |
ConversationHeader
The bar at the top of a conversation: a title with an optional line under it, such as the model in use or the assistant's status, and room for your own controls on either side.
| Prop | Type | Default | Description |
|---|---|---|---|
title required | string | Name of the conversation or of who it is with. | |
description | string | A line under the title, such as a status or the model in use. | |
leading | import('svelte').Snippet | Controls before the title, such as an avatar or a back button. | |
actions | import('svelte').Snippet | Controls after the title, such as a new chat button or a menu. | |
class | string | Additional Tailwind classes merged onto the header. |
ConversationViewport
The scrolling area of a conversation. It follows new messages to the bottom, but only while the reader is already there: once they scroll up to read something, it stays put and offers a "Jump to latest" button instead.
| Prop | Type | Default | Description |
|---|---|---|---|
children required | import('svelte').Snippet | The messages. | |
class | string | Additional Tailwind classes merged onto the viewport, which fills the space left in a flex column: give that column a height, or there is nothing to scroll. |
MessageBubble
One message. A user message is a bubble on the right, an assistant message sits on the left with its avatar and name, and a system message is a quiet centred line. In a run of messages from the same sender, set grouped on every one after the first to drop the repeated avatar and name. queued marks a message that has not been sent yet. Actions stay hidden until the pointer or keyboard focus is on the message, and are always visible on touch screens.
| Prop | Type | Default | Description |
|---|---|---|---|
children required | import('svelte').Snippet | The message content. | |
role | 'user'|'assistant'|'system' | 'assistant' | Who said it. |
name | string | The sender's name, shown above an assistant message and used for the avatar's initials. | |
avatar | string | Image URL for the sender's avatar. | |
grouped | boolean | false | Continues a run from the same sender, so the avatar and name are left out. |
queued | boolean | false | The message is waiting to be sent. |
meta | import('svelte').Snippet | Small print under the message, such as a | |
actions | import('svelte').Snippet | Controls for the message, such as | |
class | string | Additional Tailwind classes merged onto the message. |
MessageTimestamp
The time a message was sent, in the user's locale. Hovering or focusing it shows the full date and time, and a screen reader reads the full date and time instead of the short one.
| Prop | Type | Default | Description |
|---|---|---|---|
date required | Date|string|number | When the message was sent. | |
class | string | Additional Tailwind classes merged onto the time. |
MessageActions
The actions for one message, as a toolbar that is a single tab stop with the arrow keys moving between them. Copy is always there. The others appear when you pass their handler: regenerate and feedback on an assistant message, edit on a user message. items adds a menu for anything else.
| Prop | Type | Default | Description |
|---|---|---|---|
role required | 'user'|'assistant' | Whose message the actions belong to. | |
text required | string | What Copy puts on the clipboard. | |
feedback | 'up'|'down'|null | null | The feedback already given, if any. |
onRegenerate | () => void | Shows Regenerate on an assistant message and is called when it is pressed. | |
onEdit | () => void | Shows Edit on a user message and is called when it is pressed. | |
onFeedback | (feedback: 'up'|'down'|null) => void | Shows thumbs up and down on an assistant message; called with the new feedback, or | |
items | MessageActionItem[] | [] | Extra actions, shown in a menu after the others. |
class | string | Additional Tailwind classes merged onto the actions. |
MessageAttachment
A file attached to a message. An image with a url shows as a thumbnail, and any other file as a chip with its name and size. Pass onview to open the image in place, as MessageAttachments does with its Lightbox; otherwise either one opens the file in a new tab when it has a url.
| Prop | Type | Default | Description |
|---|---|---|---|
attachment required | ConversationAttachment | The file to show. | |
onview | () => void | Called when the thumbnail of an image is pressed, in place of opening it in a new tab. | |
class | string | Additional Tailwind classes merged onto the attachment. |
MessageAttachments
The files attached to one message, in a row that wraps. Its images open in a single Lightbox that pages through every image of the message; other files open in a new tab.
| Prop | Type | Default | Description |
|---|---|---|---|
attachments required | import('./conversation-message-attachment.svelte').ConversationAttachment[] | The message's files, in order. | |
class | string | Additional Tailwind classes merged onto the row. |
DateSeparator
A divider between messages from different days, labelled "Today", "Yesterday" or the date in the user's locale.
| Prop | Type | Default | Description |
|---|---|---|---|
date required | Date|string|number | A moment on the day the separator introduces. | |
class | string | Additional Tailwind classes merged onto the separator. |
ToolExecution
One tool the assistant ran, shown as a row with its state and how long it took. Expanding it reveals the arguments and the result or the error. Show a friendly label to shoppers; name is the internal tool name and appears only when there is no label.
| Prop | Type | Default | Description |
|---|---|---|---|
name required | string | Internal tool name. | |
label | string | What to show instead of the internal name. | |
state | 'pending'|'running'|'success'|'error' | 'pending' | Where the call is. |
duration | number | How long it took, in milliseconds. | |
input | * | The arguments, shown when expanded. | |
output | * | The result, shown when expanded. | |
error | string | What went wrong, shown when expanded. | |
open bindable | boolean | false | Whether the details are showing. Two-way bindable via |
class | string | Additional Tailwind classes merged onto the row. |
TypingIndicator
Three pulsing dots that show a reply is being prepared. For a screen reader it is a status that reads "Assistant is typing".
| Prop | Type | Default | Description |
|---|---|---|---|
class | string | Additional Tailwind classes merged onto the indicator. |
ConversationMessage
| Prop | Type | Default | Description |
|---|---|---|---|
id required | string | Stable key for the message. | |
role required | 'user'|'assistant'|'system' | Who said it. | |
text required | string | What was said. | |
createdAt required | Date|string|number | When it was sent. | |
status | 'queued' |
| |
attachments | import('./conversation-message-attachment.svelte').ConversationAttachment[] | Files sent with the message. | |
tools | import('./conversation-tool-execution.svelte').ConversationTool[] | Tools the assistant ran for this reply. | |
feedback | 'up'|'down' | The feedback already given on an assistant reply. |
ConversationPerson
| Prop | Type | Default | Description |
|---|---|---|---|
name required | string | Display name. | |
avatar | string | Image URL. |
MessageActionItem
| Prop | Type | Default | Description |
|---|---|---|---|
label required | string | Text of the menu entry. | |
onSelect | () => void | Called when the entry is chosen. | |
disabled | boolean | Disables the entry. | |
separator | boolean | Draws a divider in place of an entry. | |
destructive | boolean | Styles the entry as a dangerous action. |
ConversationAttachment
| Prop | Type | Default | Description |
|---|---|---|---|
id required | string | Stable key for the attachment. | |
name required | string | File name. | |
type required | string | MIME type, such as | |
size | number | Size in bytes. | |
url | string | Where the file can be opened. |
ConversationTool
| Prop | Type | Default | Description |
|---|---|---|---|
id required | string | Stable key for the tool call. | |
name required | string | Internal tool name. | |
label | string | What to show instead of the internal name. | |
state required | 'pending'|'running'|'success'|'error' | Where the call is. | |
duration | number | How long it took, in milliseconds. | |
input | * | The arguments, shown when expanded. | |
output | * | The result, shown when expanded. | |
error | string | What went wrong, shown when expanded. |