Chat message
A message with its sender, time, status and actions: the user’s in a bubble, an answer as text, a sender’s run grouped, and a streamed body that never re-wraps.
Basic #
Which sizes does the ladder have?
Ada
Two ends, 36px and 28px, with the step knob between them.
export const ChatMessageStyle = forSlots(ChatMessageSlots)(Recipes.ChatMessage({}))
export const ChatMessageGroupStyle = forSlots(ChatMessageGroupSlots)(Recipes.ChatMessageGroup({}))
const ChatAvatarStyle = forSlots(AvatarSlots)(Recipes.Avatar({ size: 'sm' }))
/** A conversation's column, as a chat pane would give it. */
const chatColumn = <Message>(h: HtmlBuilder<Message>, content: Html): Html =>
h.div([h.Style({ inlineSize: 'min(32rem, 100%)' })], [content])
// A question and its answer. What each action sends is the application's:
// copy runs a clipboard Command, and a toggle such as "Good answer" is
// `pressed` from the Model.
export const chatMessage = <Message>(
style: NamedStyle<typeof ChatMessageSlots>,
liked: boolean,
messages: { readonly onCopy: Message; readonly onLike: Message },
h: HtmlBuilder<Message>,
): Html =>
chatColumn(
h,
ChatMessage.group(
{
items: [
{
key: 'question',
from: 'user',
content: 'Which sizes does the ladder have?',
time: { at: '2026-10-17T09:30', label: '9:30' },
},
{
key: 'answer',
from: 'assistant',
author: 'Ada',
avatar: Avatar.view({ name: 'Ada Lovelace', style: ChatAvatarStyle }, h),
content: 'Two ends, 36px and 28px, with the step knob between them.',
actions: [
{ key: 'copy', label: 'Copy', onClick: messages.onCopy },
{ key: 'like', label: 'Good answer', onClick: messages.onLike, pressed: liked },
],
pinActions: true,
},
],
style: ChatMessageGroupStyle,
messageStyle: style,
},
h,
),
)Status and retry #
Book the room for Friday.
Sent
And invite Grace.
Not sent
Thanks!
Sending…
// A message the server refused offers a retry; one still on its way says so.
export const chatMessageStatus = <Message>(
style: NamedStyle<typeof ChatMessageSlots>,
retried: boolean,
onRetry: Message,
h: HtmlBuilder<Message>,
): Html =>
chatColumn(
h,
ChatMessage.group(
{
items: [
{ key: 'sent', from: 'user', content: 'Book the room for Friday.', status: 'sent' },
retried
? { key: 'failed', from: 'user', content: 'And invite Grace.', status: 'sent' }
: {
key: 'failed',
from: 'user',
content: 'And invite Grace.',
status: 'failed',
onRetry,
},
{ key: 'sending', from: 'user', content: 'Thanks!', status: 'sending' },
],
style: ChatMessageGroupStyle,
messageStyle: style,
},
h,
),
)A thread #
Grace
Deploy is green.
Grace
Shipping at noon.
Me
Great, thanks.
export const ChatThreadStyle = forSlots(ChatMessageSlots)(Recipes.ChatMessage({ layout: 'thread' }))
// A channel: everyone at the start beside their picture, and a sender's next
// messages continued under the first.
export const chatMessageThread = <Message>(
style: NamedStyle<typeof ChatMessageSlots>,
h: HtmlBuilder<Message>,
): Html => {
const person = (name: string) => Avatar.view({ name, style: ChatAvatarStyle }, h)
return chatColumn(
h,
ChatMessage.group(
{
items: [
{
key: 'deploy',
from: 'assistant',
author: 'Grace',
avatar: person('Grace Hopper'),
content: 'Deploy is green.',
time: { at: '2026-10-17T10:02', label: '10:02' },
},
{
key: 'noon',
from: 'assistant',
author: 'Grace',
avatar: person('Grace Hopper'),
content: 'Shipping at noon.',
},
{
key: 'thanks',
from: 'user',
author: 'Me',
avatar: person('Me'),
content: 'Great, thanks.',
},
],
style: ChatMessageGroupStyle,
messageStyle: style,
},
h,
),
)
}Streaming #
Ada
The ladder has two ends, 36
export const ANSWER =
'The ladder has two ends, 36 pixels and 28, and the step knob moves every control between them at once, so a region set compact shrinks its buttons, fields and rows together.'.split(
' ',
)
// The same message drawn again as each word arrives: its body wraps greedily,
// so the words already shown stay on their lines, and it is aria-busy until
// the answer is whole. Here a press brings the next words; in an application,
// a stream does.
export const chatMessageStreaming = <Message>(
style: NamedStyle<typeof ChatMessageSlots>,
shown: number,
onMore: Message,
h: HtmlBuilder<Message>,
): Html => {
const done = shown >= ANSWER.length
return h.div(
[
h.Style({
display: 'grid',
gap: '0.75rem',
justifyItems: 'start',
inlineSize: 'min(32rem, 100%)',
}),
],
[
ChatMessage.view(
{
from: 'assistant',
author: 'Ada',
content: h.p([], [ANSWER.slice(0, shown).join(' ')]),
streaming: !done,
style,
},
h,
),
Button.view(
{ label: done ? 'Start over' : 'Next words', onClick: onMore, style: ButtonStyle },
h,
),
],
)
}Usage
Each example above names the function that drew it, over the theme module Create a theme writes. The preview passes it the style picked above instead of its default.