AI message
User and assistant messages with copy, retry, feedback and branch picking.
import { AssistantMessage, MessageActions, UserMessage } from "@oration/canon/components/ai/message";import { toast } from "@oration/canon/components/toast";export function Hero() { const answer = "Nora transferred 9 of 112 supplier calls to accounts payable yesterday. Six were invoice disputes over $5,000, which always go to a person. Two were Halcyon asking about the early-payment discount, and one was Orchard Street asking to change their remit-to bank account."; return ( <div className="flex w-full max-w-xl flex-col gap-6 rounded-xl bg-card p-4 text-left shadow-border"> <UserMessage> How many supplier calls did Nora transfer to AP yesterday? </UserMessage> <AssistantMessage actionsVisibility="always" actions={ <MessageActions content={answer} onRegenerate={() => toast.add({ title: "Writing another version", description: "The current answer stays one click away.", }) } /> } > {answer} </AssistantMessage> </div> );}Usage#
AI message draws the two sides of a conversation with Copilot or a test agent. UserMessage is a right-aligned Well Gray bubble; AssistantMessage is an avatar beside plain, bubble-free content, with MessageActions (copy, regenerate, helpful, not helpful), a BranchPicker for regenerated versions and an inline MessageError with Retry. Assistant content is the document, not a chat bubble, so it holds streamed text, tool chips, plans and approval cards at full width. The common mistake is decorating it: no indigo bubbles, no colored assistant tile, and no actions while it is still streaming.
When to use
- For every turn inside a
Thread: the person's prompt asUserMessage, Copilot's answer asAssistantMessage. - For a test chat with an agent such as Nora, with the agent's avatar and persona name on each answer.
- When an answer can be copied, regenerated or rated, through
MessageActions. - When a regenerated answer should keep its earlier versions one click away, through
BranchPicker. - When a response fails part way, to show the failure and a Retry where the answer would have been.
When not to use
- For a live call transcript between Nora and a supplier. Use Live transcript
- For a customer conversation on a ticket or in the Contact Center inbox. Use Bubble
- For a one-off generated summary on a record page, outside a conversation. Use Streaming text
- For a single tool call inside an answer. Use Tool chip
- For a failure that blocks the whole panel, such as Copilot being unavailable. Use Alert
The Quiet Indigo Rule
The Tint Well Rule
The Label-Beside-Color Rule
aria-pressed, so it reads without color.Anatomy#
- Attachments. Optional chips above the user bubble, right-aligned: mentioned records, the command used, attached files.
- User bubble. Well Gray, 12px corners, 8px by 12px padding, 14px text with preserved line breaks, at most 85% of the thread's width.
- Assistant avatar. A 24px neutral tile with a sparkles icon by default. Pass an
AgentAvatarfor a named agent, ornullto drop the column. - Name. Optional 12px Slate Meta at weight 500, such as Nora. Without it the content nudges down 2px to line up with the avatar.
- Content. 14px reading text at a relaxed line height, no bubble. Holds streamed text, tool chips, plans and cards.
- Message actions. A toolbar of 24px ghost icon buttons with tooltips: Copy, Regenerate, Helpful, Not helpful, then anything passed as children.
- Branch picker. Previous and next version buttons around a tabular 2/3 counter. Hidden when there is one version.
Examples#
User messages
A plain prompt, a prompt sent with a command and a mention (chips above the bubble), and one with an edit action revealed on hover. Line breaks are kept.
import { UserMessage } from "@oration/canon/components/ai/message";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { AtSignIcon, PencilIcon, SlashIcon } from "lucide-react";export function UserMessages() { return ( <div className="flex w-full max-w-xl flex-col gap-6 text-left"> <UserMessage> Which suppliers are in Friday's payment run? </UserMessage> <UserMessage attachments={ <div className="flex max-w-[85%] flex-wrap justify-end gap-1"> <span className="inline-flex h-6 items-center gap-1 rounded-md bg-muted/70 px-1.5 text-xs text-muted-foreground"> <SlashIcon aria-hidden="true" className="size-3" /> summarize </span> <span className="inline-flex h-6 items-center gap-1 rounded-md bg-muted/70 px-1.5 text-xs text-muted-foreground"> <AtSignIcon aria-hidden="true" className="size-3" /> Northwind Freight </span> </div> } > Open invoices and anything on hold </UserMessage> <UserMessage actions={ <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-xs" aria-label="Edit message" className="text-muted-foreground hover:text-foreground" onClick={() => toast.add({ title: "Message moved to the composer", description: "Edit it and send again.", }) } /> } > <PencilIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Edit message</TooltipContent> </Tooltip> } > { "Draft a note to Aisha at Halcyon:\n- the $760.00 is the 2% discount\n- remittance attached" } </UserMessage> </div> );}Avatar and name
Copilot uses the neutral default. A test chat with Nora passes her agent avatar and persona name. Narrow panels pass avatar={null}.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { AssistantMessage } from "@oration/canon/components/ai/message";export function Avatars() { return ( <div className="flex w-full max-w-xl flex-col gap-6 text-left"> <AssistantMessage> Copilot's default mark: a neutral tile, never an indigo one. </AssistantMessage> <AssistantMessage avatar={ <AgentAvatar agent={{ id: "ag_payment_status", name: "Nora" }} size={24} /> } name="Nora" > Thanks for calling Cedarline accounts payable. I can see invoice INV-20931 is scheduled for Tuesday's ACH run. </AssistantMessage> <div className="w-72 rounded-xl bg-muted/70 p-3"> <AssistantMessage avatar={null}> In a narrow assist panel, drop the avatar column and let the answer use the full width. </AssistantMessage> </div> </div> );}Regenerate and versions
Regenerate streams a new version, then the branch picker pages between all of them. Actions hide while the new version streams.
import { AssistantMessage, BranchPicker, MessageActions, UserMessage } from "@oration/canon/components/ai/message";import * as React from "react";export function Versions() { const drafts = [ "Hi Tomás, OS-4471 was paid by ACH on Friday, September 25. The trace number is 091000019284113 and the remittance is attached.", "Hi Tomás, good news: Orchard Street's invoice OS-4471 went out Friday by ACH. You should see it today; the trace is 091000019284113 if your bank asks.", "Tomás, OS-4471 was paid Friday, September 25, by ACH (trace 091000019284113). Remittance attached. Reply here if it hasn't landed by Wednesday.", ]; const [count, setCount] = React.useState(1); const [index, setIndex] = React.useState(0); const [streaming, setStreaming] = React.useState(false); React.useEffect(() => { if (!streaming) return; const id = window.setTimeout(() => setStreaming(false), 1200); return () => window.clearTimeout(id); }, [streaming]); const text = drafts[index % drafts.length] ?? ""; return ( <div className="flex w-full max-w-xl flex-col gap-6 text-left"> <UserMessage> Draft a reply to Orchard Street about OS-4471. </UserMessage> <AssistantMessage status={streaming ? "streaming" : "done"} actionsVisibility="always" actions={ <MessageActions content={text} onRegenerate={() => { setIndex(count); setCount((n) => n + 1); setStreaming(true); }} > <BranchPicker index={index} count={count} onPrev={() => setIndex((i) => i - 1)} onNext={() => setIndex((i) => i + 1)} /> </MessageActions> } > {streaming ? ( <span className="text-muted-foreground text-shimmer"> Writing another version </span> ) : ( text )} </AssistantMessage> </div> );}Controlled feedback
Pass feedback and onFeedback to store ratings. Each rating raises a toast with Undo, and Undo calls onFeedback with the previous value.
Not rated yet.
import { AssistantMessage, MessageActions, type MessageFeedback } from "@oration/canon/components/ai/message";import * as React from "react";export function Feedback() { const [feedback, setFeedback] = React.useState<MessageFeedback>(null); const answer = "Northwind Freight's remit-to email changed on September 22, so the remittance for PMT-58213 went to the old inbox. I resent it to ap@northwindfreight.example."; return ( <div className="flex w-full max-w-xl flex-col gap-3 text-left"> <AssistantMessage actionsVisibility="always" actions={ <MessageActions content={answer} feedback={feedback} onFeedback={setFeedback} /> } > {answer} </AssistantMessage> <p className="pl-8 text-xs text-muted-foreground"> {feedback === "up" ? "Saved as helpful." : feedback === "down" ? "Sent to review." : "Not rated yet."} </p> </div> );}Errors and retry
With status="error" the error slot renders where the answer would be. Retry streams the answer again.
import { AssistantMessage, MessageActions, MessageError, UserMessage } from "@oration/canon/components/ai/message";import { Button } from "@oration/canon/components/button";import * as React from "react";export function Errors() { const [status, setStatus] = React.useState<"error" | "streaming" | "done">( "error", ); React.useEffect(() => { if (status !== "streaming") return; const id = window.setTimeout(() => setStatus("done"), 1200); return () => window.clearTimeout(id); }, [status]); return ( <div className="flex w-full max-w-xl flex-col gap-6 text-left"> <UserMessage>Has Halcyon's payment cleared?</UserMessage> <AssistantMessage status={status} actions={ status === "done" ? ( <MessageActions content="PMT-58190 cleared on Monday, September 28." /> ) : undefined } actionsVisibility="always" error={ <MessageError title="Couldn't reach the ERP" description="The payment lookup timed out after 2 seconds. Nothing was changed." onRetry={() => setStatus("streaming")} /> } > {status === "streaming" ? ( <span className="text-muted-foreground text-shimmer"> Checking PMT-58190 </span> ) : status === "done" ? ( "PMT-58190 cleared on Monday, September 28. Halcyon's bank shows it as received." ) : null} </AssistantMessage> {status === "done" ? ( <Button type="button" variant="ghost" size="sm" className="self-start" onClick={() => setStatus("error")} > Show the error again </Button> ) : null} </div> );}Actions on hover
Earlier answers reveal their actions on hover or focus; the latest keeps them visible. Touch screens always show them.
import { AssistantMessage, MessageActions } from "@oration/canon/components/ai/message";export function HoverActions() { return ( <div className="flex w-full max-w-xl flex-col gap-6 text-left"> <AssistantMessage actions={ <MessageActions content="Wen Zhou Consulting's W-9 expired in August." /> } > Wen Zhou Consulting's W-9 expired in August. Hover or Tab into this earlier answer to see its actions. </AssistantMessage> <AssistantMessage actionsVisibility="always" actions={ <MessageActions content="I sent Wen Zhou Consulting a W-9 request." /> } > I sent Wen Zhou Consulting a W-9 request. The latest answer keeps its actions visible. </AssistantMessage> </div> );}States#
import { AssistantMessage, MessageActions, MessageError } from "@oration/canon/components/ai/message";import { Button } from "@oration/canon/components/button";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, ChevronLeftIcon, CopyIcon, RefreshCwIcon, ThumbsUpIcon,} from "lucide-react";export function StatesMatrix() { const ghost = "pointer-events-none text-muted-foreground hover:text-foreground aria-pressed:text-foreground"; const columns = [ { label: "Rest", node: ( <Button type="button" variant="ghost" size="icon-xs" tabIndex={-1} aria-label="Copy" className={ghost} > <CopyIcon aria-hidden="true" /> </Button> ), }, { label: "Hover", node: ( <Button type="button" variant="ghost" size="icon-xs" tabIndex={-1} aria-label="Copy" className={cn(ghost, "bg-muted text-foreground")} > <CopyIcon aria-hidden="true" /> </Button> ), }, { label: "Focus", node: ( <Button type="button" variant="ghost" size="icon-xs" tabIndex={-1} aria-label="Regenerate" className={cn(ghost, "border-ring ring-3 ring-ring/40")} > <RefreshCwIcon aria-hidden="true" /> </Button> ), }, { label: "Pressed", node: ( <Button type="button" variant="ghost" size="icon-xs" tabIndex={-1} aria-label="Helpful" aria-pressed className={ghost} > <ThumbsUpIcon aria-hidden="true" className="fill-current" /> </Button> ), }, { label: "Copied", node: ( <Button type="button" variant="ghost" size="icon-xs" tabIndex={-1} aria-label="Copied" className={ghost} > <CheckIcon aria-hidden="true" className="text-success" /> </Button> ), }, { label: "Disabled", node: ( <Button type="button" variant="ghost" size="icon-xs" tabIndex={-1} aria-label="Previous version" disabled className={ghost} > <ChevronLeftIcon aria-hidden="true" /> </Button> ), }, ]; return ( <div className="flex w-full flex-col gap-6 text-left"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-6"> {columns.map((column) => ( <div key={column.label} className="flex flex-col items-center gap-2" > {column.node} <span className="text-xs text-muted-foreground"> {column.label} </span> </div> ))} </div> <div className="grid gap-4 md:grid-cols-3"> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground"> Streaming </span> <AssistantMessage status="streaming" actions={<MessageActions content="" />} > INV-20931 is approved and </AssistantMessage> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Done</span> <AssistantMessage actionsVisibility="always" actions={ <MessageActions content="INV-20931 is approved." /> } > INV-20931 is approved. </AssistantMessage> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Error</span> <AssistantMessage status="error" error={<MessageError />} /> </div> </div> </div> );}| State | Treatment |
|---|---|
| Streaming | status="streaming" sets aria-busy and hides the actions until the answer finishes. |
| Done | Actions appear, on hover or always, per actionsVisibility. |
| Error | status="error" renders the error slot, usually a MessageError: a 10% red tint with an alert icon, a title, an optional description and an outline Retry. |
| Actions hidden | With actionsVisibility="hover", pointer devices that can hover see actions only while the turn is hovered or has focus inside it. Touch devices always see them. |
| Action hover | Ghost icon buttons fill Well Gray and the icon turns from Slate Meta to ink over 150ms. |
| Action focus visible | Indigo border and a 3px Focus Indigo ring at 40%. |
| Copied | Copy swaps to a Ledger Green check and its name to Copied for 1.6 seconds. |
| Rated | The chosen thumb fills with ink and sets aria-pressed. Pressing it again clears the rating. |
| First or last version | The branch picker disables Previous on the first version and Next on the last. |
Behavior#
- Actions never render while
statusisstreaming, so nobody copies or rates half an answer. - Copy writes
contentto the clipboard and confirms with a check for 1.6 seconds. Omitcontentto hide Copy. - Rating raises a toast, Marked as helpful or Marked as not helpful, with Undo. Not helpful adds This answer goes to review so future answers improve. Clearing a rating raises no toast.
feedbackandonFeedbackmake the rating controlled. Leavefeedbackundefined and the component keeps its own state.- Regenerate only renders when
onRegenerateis passed. The app adds a version, streams it, and passes the newindexandcounttoBranchPicker. BranchPickertakes a zero-basedindex, renders nothing whencountis 1 or less, and announces Version 2 of 3 politely when the page changes.- Children of
MessageActionsrender after the built-in buttons, which is whereBranchPickerand any extra action go. - The hover reveal is a 150ms opacity fade keyed to
group-hover/messageandgroup-focus-within/message, so Tab reveals the actions too.
Do and don't#
Content#
- User messages are the person's words, verbatim. Don't rewrite or trim them.
- Assistant answers lead with the answer: Yes. INV-20931 is scheduled for Tuesday's ACH run. Then the supporting detail.
- Name things the way Cedarline does: invoice and payment IDs exactly (INV-20931, PMT-58213), supplier names in full, amounts with cents.
MessageErrortitles say what didn't happen: Couldn't finish this response (the default), Couldn't reach the ERP. The description says why, in plain words, and never blames the person.retryLabelstays a verb: Retry, Try again, Reconnect.- Agent names on
AssistantMessageare the persona the caller hears, such as Nora, not the internal agent name.
Accessibility#
- Messages render inside the thread's
role="log". A streaming assistant turn isaria-busy, so assistive tech waits for the finished text. - Every action is an icon button with an
aria-labeland a tooltip with the same words: Copy (then Copied), Regenerate, Helpful, Not helpful, Previous version, Next version. - Ratings expose
aria-pressed. The thumbs fill as well as darken, so the pressed state reads without color. - The branch picker is a group named Response versions with a polite Version 2 of 3 announcement; the visible 2/3 is hidden from screen readers.
MessageErrorisrole="alert", so the failure is announced as soon as it appears.- The default assistant avatar is
aria-hidden; give the turn anamewhen the speaker matters. - Hidden actions are only transparent, not removed, so keyboard users can still Tab to them and focus reveals them.
| Keys | Action |
|---|---|
| Tab | Moves through each action in order, revealing hidden actions. |
| Enter | Activates the focused action. |
| Space | Activates the focused action. |
Design tokens#
| Token | Used for |
|---|---|
--muted | User bubble, assistant avatar tile, action hover fill |
--muted-foreground | Avatar icon, name, resting action icons, error description |
--foreground | Message text, hovered and pressed action icons |
--success | Copied check |
--destructive | Error tint at 10% (20% in dark) and its icon |
--ring | Action focus ring at 40% |
--radius-xl | 12px user bubble corners |
--radius-lg | Error well corners |
API reference#
UserMessage
The person's turn, right-aligned.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | React.ReactNode | No default | The message text. Line breaks are kept. |
attachments | React.ReactNode | No default | Rendered above the bubble, e.g. mention chips. |
actions | React.ReactNode | No default | Rendered below the bubble and revealed on hover or focus. |
className | string | No default | Merged onto the root. |
AssistantMessage
An assistant or agent turn.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | No default | The answer and anything inside it. |
avatar | React.ReactNode | null | No default | Replaces the default tile. null removes the avatar column. |
name | string | No default | Speaker name above the content. |
status | "streaming" | "done" | "error" | "done" | Streaming hides actions; error renders error. |
actions | React.ReactNode | No default | Usually MessageActions. Hidden while streaming. |
error | React.ReactNode | No default | Rendered only when status is error. Usually MessageError. |
actionsVisibility | "hover" | "always" | "hover" | Use always on the latest turn. |
className | string | No default | Merged onto the root. |
MessageActions
Copy, regenerate and ratings in a toolbar.
| Prop | Type | Default | Description |
|---|---|---|---|
content | string | No default | Plain text to copy. Omit to hide Copy. |
onRegenerate | () => void | No default | Shows Regenerate. |
feedback | "up" | "down" | null | No default | Controlled rating. Leave undefined for uncontrolled. |
onFeedback | (value: "up" | "down" | null) => void | No default | Called on every change, including Undo. |
children | React.ReactNode | No default | Extra actions after the built-ins, e.g. BranchPicker. |
className | string | No default | Merged onto the toolbar. |
BranchPicker
Pages between regenerated versions.
| Prop | Type | Default | Description |
|---|---|---|---|
indexRequired | number | No default | Zero-based current version. |
countRequired | number | No default | Number of versions. Renders nothing at 1 or less. |
onPrevRequired | () => void | No default | Previous version. |
onNextRequired | () => void | No default | Next version. |
className | string | No default | Merged onto the group. |
MessageError
An inline failure with an optional retry.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "Couldn't finish this response" | What didn't happen. |
description | React.ReactNode | No default | Why, in plain words. |
onRetry | () => void | No default | Shows an outline Retry button. |
retryLabel | string | "Retry" | The retry button's label. |
className | string | No default | Merged onto the alert. |
AssistantAvatar
The default assistant mark, exported for custom layouts. Also exported: the MessageStatus and MessageFeedback types.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged onto the tile. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
MessageError isn't used anywhere in the app. When a Copilot turn is stopped, the app writes its own one-line note instead.
UserMessage's actions slot isn't used in the app, so there is no edit-and-resend for a prompt.
Copy shows Copied even when the clipboard write fails; the error is swallowed.
Actions are hidden only while streaming. In the error state they render beside the error, so Copy and the ratings act on an answer that doesn't exist. Pass actions only once the turn is done.
MessageActions is role="toolbar" but has no arrow-key navigation. Each button is its own Tab stop, which a toolbar role doesn't lead people to expect.
AssistantAvatar and the MessageStatus and MessageFeedback types are exported but not listed in the registry.