Message
A role-aware container for one turn, with chip-segmented text and selection hooks.
"use client";
import { Message } from "@intentface/chat/message";
import { Check, Copy } from "@keyline-icons/react";
import { useState } from "react";
// Message.Root stamps data-role / data-last / data-error and imposes no layout;
// the bubble, alignment, and actions are all yours.
const MESSAGES = [
{ id: "q", role: "user", text: "How do I center a div?" },
{
id: "a",
role: "assistant",
text: "Use flexbox on the parent: display: flex, then justify-content: center and align-items: center.",
},
];
export const Basic = () => (
<div className="flex w-full max-w-xl flex-col gap-5">
{MESSAGES.map((message, index) => (
<Message.Root
key={message.id}
role={message.role}
isLast={index === MESSAGES.length - 1}
className="group flex w-full flex-col gap-1.5 data-[role=user]:items-end"
>
{/* data-role sits on Root, so the bubble reads it through the group. */}
<Message.Text className="text-sm text-zinc-700 leading-6 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-[20px] group-data-[role=user]:bg-white group-data-[role=user]:px-3.5 group-data-[role=user]:py-1.5 group-data-[role=user]:text-zinc-900 group-data-[role=user]:leading-6 group-data-[role=user]:shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:text-zinc-300 dark:group-data-[role=user]:bg-zinc-800 dark:group-data-[role=user]:text-zinc-100 dark:group-data-[role=user]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
{message.text}
</Message.Text>
{message.role === "assistant" && <CopyButton value={message.text} />}
</Message.Root>
))}
</div>
);
const CopyButton = ({ value }: { value: string }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={handleCopy}
aria-label="Copy message"
className="-ml-1.5 flex size-7 cursor-pointer items-center justify-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
{copied ? <Check className="size-[15px]" /> : <Copy className="size-[15px]" />}
</button>
);
};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.
"use client";
import { Chip } from "@intentface/chat/chip";
import { Message, type MessageChipSegment } from "@intentface/chat/message";
import { File, FileText, Wrench } from "@keyline-icons/react";
/*
* A message whose text carries inline chip references, and the renderers that
* turn them back into chips.
*
* The wire format is a markdown-shaped link: `[Label](chip:prefix:value)`, with
* everything the chip needs riding along inside the token. That is the point —
* a stored message reconstructs its own chips from its text alone, with no
* sidecar array of metadata to keep in sync or migrate.
*
* `Message.Text` parses the tokens and calls `renderChip` for each one. What a
* chip looks like, and whether a prefix earns a different variant, is decided
* here at render time rather than baked into the stored text.
*/
const TEXT =
"I compared [pricing.tsx](chip:file:src/app/pricing.tsx) against " +
"[the Q3 brief](chip:doc:q3-brief) and pulled figures from " +
"[web-search](chip:tool:web-search). The deprecated rate in " +
"[legacy.ts](chip:file:src/legacy.ts) is the only mismatch.";
export const Chips = () => (
<div className="w-full max-w-xl">
{/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */}
<Message.Root role="assistant">
<Message.Text
renderChip={renderChip}
className="text-sm text-zinc-700 leading-8 dark:text-zinc-300"
>
{TEXT}
</Message.Text>
</Message.Root>
</div>
);
/** The prefix decides the variant and the icon; neither is stored in the text. */
const renderChip = (chip: MessageChipSegment, index: number) => (
<Chip.Root
key={`${chip.prefix}-${chip.value}-${index}`}
variant={chip.prefix === "tool" ? "accent" : "primary"}
className="mx-0.5 inline-flex h-6 items-center gap-1 rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-2 align-middle font-medium text-xs text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] data-[variant=accent]:bg-[#0169cc]/10 data-[variant=accent]:bg-none data-[variant=accent]:text-[#0169cc] data-[variant=accent]:shadow-none dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:data-[variant=accent]:bg-[#4c9bea]/15 dark:data-[variant=accent]:text-[#4c9bea] dark:data-[variant=accent]:shadow-none"
>
<Chip.Icon className="flex items-center">
{chip.prefix === "file" ? (
<File className="size-3.5" />
) : chip.prefix === "doc" ? (
<FileText className="size-3.5" />
) : (
<Wrench className="size-3.5" />
)}
</Chip.Icon>
<Chip.Label>{chip.label}</Chip.Label>
</Chip.Root>
);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.
"use client";
import { Message, useMessageSelection } from "@intentface/chat/message";
import { useState } from "react";
/*
* Selection is exposed as a hook rather than a part, so the toolbar it drives
* stays entirely yours — this one is a small bar, but a popover anchored to the
* range would read the same value.
*
* `useMessageSelection` takes the element to scope to and returns the settled
* selection inside it, or null. Scoping is the whole point: dragging across two
* messages, or selecting in the page around them, reports nothing here.
*/
export const Selection = () => {
const [scope, setScope] = useState<HTMLElement | null>(null);
const selection = useMessageSelection(scope);
const [quoted, setQuoted] = useState<string | null>(null);
return (
<div className="flex w-full max-w-xl flex-col gap-3">
{/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */}
<Message.Root
role="assistant"
ref={setScope}
className="rounded-xl bg-white px-4 py-3.5 shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
>
<Message.Text className="text-sm text-zinc-700 leading-6 dark:text-zinc-300">
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.
</Message.Text>
</Message.Root>
{/* Rendered outside the message, and still scoped to it. */}
<div className="flex min-h-8 items-center justify-center gap-2">
{selection ? (
<>
<span className="max-w-64 truncate text-xs text-zinc-400 dark:text-zinc-500">
“{selection.text}”
</span>
<button
type="button"
onClick={() => setQuoted(selection.text)}
className="h-8 shrink-0 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3.5 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
>
Quote
</button>
</>
) : (
<span className="text-xs text-zinc-400 dark:text-zinc-500">
Nothing selected in this message.
</span>
)}
</div>
{quoted && (
<blockquote className="border-zinc-950/10 border-l-2 pl-3 text-sm text-zinc-500 italic leading-6 dark:border-white/10 dark:text-zinc-400">
{quoted}
</blockquote>
)}
</div>
);
};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 | Details |
|---|---|---|---|
role | string | (required) | |
isLast | boolean | false | |
isError | boolean | false |
| Attribute | Description |
|---|---|
data-message | |
data-role | |
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 | Details |
|---|---|---|---|
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 | Details |
|---|---|---|---|
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.