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.
- Memoise your row, not ours — see Why the memo boundary is yours. Getting this wrong re-renders every message on every stream chunk.
- 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>Examples
Reconstructing chips from the text
The wire format is a markdown-shaped link, [Label](chip:prefix:value), with
everything the chip needs inside the token. A stored message therefore rebuilds
its own chips from its text alone, with no sidecar metadata to keep in sync.
renderChip decides what one looks like at render time.
Acting on a text selection
Selection is a hook rather than a part, so the toolbar it drives stays yours.
useMessageSelection takes the element to scope to and reports the settled
selection inside it. Scoping is the point: a drag across two messages, or
anywhere else on the page, reports nothing.
Why the memo boundary is yours
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.
API reference
All three parts accept className, style, and render
(see Styling).
Message.Root
One turn's container. Reports the role and position as data attributes and
renders no layout of its own, so the bubble, avatar and actions around it stay
yours. Renders a <div> element.
| Prop | Type | Default |
|---|---|---|
role | string | (required) |
isLast | boolean | false |
isError | boolean | false |
| Attribute | Values | Details |
|---|---|---|
data-message | — | |
data-role | string | |
data-error | — | |
data-last | — |
Message.Turn
Groups consecutive messages from one role into a single visual turn. Renders a
<div> element, and takes no props of its own beyond the shared ones.
Message.Text
Message text with inline chips reconstructed from the wire format, so a stored
message rebuilds its own chips with no sidecar metadata. Renders a <span>
element.
| 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.