Message

A role-aware container for one turn, with chip-segmented text and selection hooks.

How do I center a div?
Use flexbox on the parent: display: flex, then justify-content: center and align-items: center.

Usage guidelines

  • One turn's containerMessage.Root reports the role and position as data attributes and renders no layout of its own.
  • Segmented textMessage.Text reconstructs 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.

I compared pricing.tsx against the Q3 brief and pulled figures from web-search. The deprecated rate in legacy.ts is the only mismatch.

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.

useMemo caches a computed value and useCallback caches a function reference. Reach for either only when something downstream is memoised, because the comparison itself is not free. Select any of this sentence.
Nothing selected in this message.

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. isStreaming should 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.

PropTypeDefault
rolestring
(required)
isLastboolean
false
isErrorboolean
false
AttributeValuesDescription
data-messageThe message root.
data-rolestringThe message role you passed (commonly system / user / assistant).
data-errorPresent when isError is true.
data-lastPresent when isLast is true.

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.

PropTypeDefault
childrenstring
(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.

PropTypeDefault
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.