Message
A role-aware container for one turn, with chip-segmented text and selection hooks.
Usage guidelines
- One turn's container —
Message.Rootreports the role and position as data attributes and renders no layout of its own. - Segmented text —
Message.Textreconstructs inline chips from the wire format and takes render callbacks for both runs and chips. - Everything else is yours — bubbles, avatars, copy buttons, source pills, attachment previews and markdown rendering are composed by you around these parts.
- Get started — see Quick start to add the package.
Anatomy
The package provides three parts. Message.Root is the only required one:
<Message.Turn>
<Message.Root role={role} isLast={isLast} isError={isError}>
<Message.Text>{text}</Message.Text>
</Message.Root>
</Message.Turn>Everything a finished chat row needs beyond that — the bubble surface, actions, sources, attachments, markdown — is your own markup, styled off the root's data attributes:
<Message.Root role="assistant" isLast className="group flex flex-col gap-2">
<Markdown>{content}</Markdown>
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
<button type="button" onClick={() => copy(content)}>Copy</button>
<button type="button" onClick={regenerate}>Regenerate</button>
</div>
</Message.Root>Performance
Message is compositional — you pass its parts as children — which means the
package cannot memoize rows for you: a parent re-render re-creates the children
elements, so a memo inside Message would compare fresh trees and never
bail. The memo boundary has to be your row component, the one that receives
the message object and derives everything inside:
const ChatMessageItem = memo(({ message, isLast, isStreaming }: ChatMessageItemProps) => {
const { parts } = message;
// segmentation, part mapping, actions — all derived in here
return <Message.Root role={message.role} isLast={isLast}>{/* … */}</Message.Root>;
});
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
isLast={message.id === lastMessageId}
isStreaming={message.id === lastMessageId && isStreaming}
/>
))}Three rules keep the memo effective while a reply streams:
- Pass the original message object. Finished messages keep reference
identity across stream chunks; spreading (
{ parts, ...message }) mints a fresh object every render and silently defeats the memo. - Make flags per-message.
isStreamingshould mean this message is streaming — passing the chat-wide status re-renders every row on each status transition. - Take callbacks from stable context inside the row, not as inline props from the map.
Done right, a stream chunk re-renders exactly one row. See Composer performance for the full render model.
Accessibility
- Role drives
data-role, and error/last state drivedata-error/data-laston the root, so styling and assistive context stay in sync. - Speaker identity is yours to announce.
roleis an opaque string the package only surfaces asdata-role— alignment and colour are invisible to assistive tech. Give each message a visually-hidden{role} saidprefix, or anaria-labelon the root, so a transcript read top-to-bottom attributes its turns. - Error, stopped and loading markers are your markup: give an inline failure
marker
role="alert"so it announces immediately, and quieter states ("stopped", "generating…")role="status". - Actions you add should be real buttons with accessible names.
API reference
All three parts accept className, style, and render
(see Styling).
Message.Root
The container. Renders data-message.
| Prop | Type | Default |
|---|---|---|
role | string | (required) |
isLast | boolean | false |
isError | boolean | false |
| Attribute | Values | Description |
|---|---|---|
data-message | — | The message root. |
data-role | string | The message role you passed (commonly system / user / assistant). |
data-error | — | Present when isError is true. |
data-last | — | Present when isLast is true. |
Message.Turn
Groups consecutive messages from one role into a single visual turn. Renders
data-message-turn and takes no props of its own beyond the shared ones.
Message.Text
Plain text with inline chips reconstructed from the wire format. Renders
data-message-text.
| Prop | Type | Default |
|---|---|---|
children | string | (required) |
renderText | (text, index) => ReactNode | — |
renderChip | (chip, index) => ReactNode | — |
Selection
Text selection scoped to a message is exposed as functions rather than a part, so the toolbar (or whatever you build on it) stays yours.
| Prop | Type | Default |
|---|---|---|
useMessageSelection | (scope: HTMLElement | null) => MessageSelection | null | — |
useMessageSelectionScope | () => { anchorRef, contentElement } | — |
readMessageSelection | (scope: HTMLElement) => MessageSelection | null | — |
Types
MessageSelection, MessageState, MessageChipSegment, MessageRootProps,
MessageTurnProps, and MessageTextProps are exported from
@intentface/chat/message.