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

Accessibility

  • Role drives data-role, and error/last state drive data-error/data-last on the root, so styling and assistive context stay in sync.
  • Speaker identity is yours to announce. role is an opaque string the package only surfaces as data-role — alignment and colour are invisible to assistive tech. Give each message a visually-hidden {role} said prefix, or an aria-label on 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.

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

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.