Skip to content

AI message

User and assistant messages with copy, retry, feedback and branch picking.

Status
Beta
Category
AI
Adoption
Not used yet
import { UserMessage } from "@oration/canon/components/ai/message";
packages/canon/src/components/ai/message.tsx
How many supplier calls did Nora transfer to AP yesterday?
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.
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 as UserMessage, Copilot's answer as AssistantMessage.
  • 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

Neither side is indigo. The user bubble is Well Gray, the assistant mark is a neutral tile with a sparkles icon, and message actions are ghost icons in Slate Meta. Indigo in a thread belongs to Send and the focus ring.

The Tint Well Rule

The user bubble and the error are tints (Well Gray and a 10% Signal Red), not bordered cards. Anything inside an assistant answer that needs a region, such as sources or a field list, uses a 70% Well Gray well.

The Label-Beside-Color Rule

An error is a red icon plus a sentence that says what failed. A pressed rating fills its thumb and sets aria-pressed, so it reads without color.

Anatomy#

Orchard Street
Why did OS-4471 get paid late?
Copilot
It was held for a missing W-9. Orchard Street sent the form on September 21 and the invoice went out in Friday's run.
  1. Attachments. Optional chips above the user bubble, right-aligned: mentioned records, the command used, attached files.
  2. User bubble. Well Gray, 12px corners, 8px by 12px padding, 14px text with preserved line breaks, at most 85% of the thread's width.
  3. Assistant avatar. A 24px neutral tile with a sparkles icon by default. Pass an AgentAvatar for a named agent, or null to drop the column.
  4. Name. Optional 12px Slate Meta at weight 500, such as Nora. Without it the content nudges down 2px to line up with the avatar.
  5. Content. 14px reading text at a relaxed line height, no bubble. Holds streamed text, tool chips, plans and cards.
  6. Message actions. A toolbar of 24px ghost icon buttons with tooltips: Copy, Regenerate, Helpful, Not helpful, then anything passed as children.
  7. 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.

Which suppliers are in Friday's payment run?
summarizeNorthwind Freight
Open invoices and anything on hold
Draft a note to Aisha at Halcyon: - the $760.00 is the 2% discount - remittance attached
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}.

Copilot's default mark: a neutral tile, never an indigo one.
Nora
Thanks for calling Cedarline accounts payable. I can see invoice INV-20931 is scheduled for Tuesday's ACH run.
In a narrow assist panel, drop the avatar column and let the answer use the full width.
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.

Draft a reply to Orchard Street about OS-4471.
Hi Tomás, OS-4471 was paid by ACH on Friday, September 25. The trace number is 091000019284113 and the remittance is attached.
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.

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.

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.

Has Halcyon's payment cleared?
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.

Wen Zhou Consulting's W-9 expired in August. Hover or Tab into this earlier answer to see its actions.
I sent Wen Zhou Consulting a W-9 request. The latest answer keeps its actions visible.
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#

Rest
Hover
Focus
Pressed
Copied
Disabled
Streaming
INV-20931 is approved and
Done
INV-20931 is approved.
Error
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>    );}
States
StateTreatment
Streamingstatus="streaming" sets aria-busy and hides the actions until the answer finishes.
DoneActions appear, on hover or always, per actionsVisibility.
Errorstatus="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 hiddenWith 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 hoverGhost icon buttons fill Well Gray and the icon turns from Slate Meta to ink over 150ms.
Action focus visibleIndigo border and a 3px Focus Indigo ring at 40%.
CopiedCopy swaps to a Ledger Green check and its name to Copied for 1.6 seconds.
RatedThe chosen thumb fills with ink and sets aria-pressed. Pressing it again clears the rating.
First or last versionThe branch picker disables Previous on the first version and Next on the last.

Behavior#

  • Actions never render while status is streaming, so nobody copies or rates half an answer.
  • Copy writes content to the clipboard and confirms with a check for 1.6 seconds. Omit content to 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.
  • feedback and onFeedback make the rating controlled. Leave feedback undefined and the component keeps its own state.
  • Regenerate only renders when onRegenerate is passed. The app adds a version, streams it, and passes the new index and count to BranchPicker.
  • BranchPicker takes a zero-based index, renders nothing when count is 1 or less, and announces Version 2 of 3 politely when the page changes.
  • Children of MessageActions render after the built-in buttons, which is where BranchPicker and any extra action go.
  • The hover reveal is a 150ms opacity fade keyed to group-hover/message and group-focus-within/message, so Tab reveals the actions too.

Do and don't#

When does Northwind get paid?
Tuesday, September 29, in the ACH run. INV-20931 is approved.
Do. Draw the assistant as plain text beside a neutral avatar, and the person's turn as a Well Gray bubble.
When does Northwind get paid?
Tuesday, September 29, in the ACH run. INV-20931 is approved.
Don't. Put either side in an indigo bubble. It spends the accent on decoration and makes long answers hard to read.
Do. Put a failure where the answer would have been, say what failed, and offer Retry.
Something went wrong.
Don't. Print a red Something went wrong with no way forward, or leave the turn blank and raise a toast.
Do. Show actions always on the latest answer and on hover for earlier ones.
Don't. Pin a full action row under every message. The thread turns into a column of icons.

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.
  • MessageError titles 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.
  • retryLabel stays a verb: Retry, Try again, Reconnect.
  • Agent names on AssistantMessage are 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 is aria-busy, so assistive tech waits for the finished text.
  • Every action is an icon button with an aria-label and 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.
  • MessageError is role="alert", so the failure is announced as soon as it appears.
  • The default assistant avatar is aria-hidden; give the turn a name when the speaker matters.
  • Hidden actions are only transparent, not removed, so keyboard users can still Tab to them and focus reveals them.
Keyboard interactions
KeysAction
TabMoves through each action in order, revealing hidden actions.
EnterActivates the focused action.
SpaceActivates the focused action.

Design tokens#

Design tokens
TokenUsed for
--mutedUser bubble, assistant avatar tile, action hover fill
--muted-foregroundAvatar icon, name, resting action icons, error description
--foregroundMessage text, hovered and pressed action icons
--successCopied check
--destructiveError tint at 10% (20% in dark) and its icon
--ringAction focus ring at 40%
--radius-xl12px user bubble corners
--radius-lgError well corners

API reference#

UserMessage

The person's turn, right-aligned.

Other props spread onto Nothing. Only the props below are read..

Props of UserMessage
PropTypeDefaultDescription
childrenRequiredReact.ReactNodeNo defaultThe message text. Line breaks are kept.
attachmentsReact.ReactNodeNo defaultRendered above the bubble, e.g. mention chips.
actionsReact.ReactNodeNo defaultRendered below the bubble and revealed on hover or focus.
classNamestringNo defaultMerged onto the root.

AssistantMessage

An assistant or agent turn.

Other props spread onto Nothing. Only the props below are read..

Props of AssistantMessage
PropTypeDefaultDescription
childrenReact.ReactNodeNo defaultThe answer and anything inside it.
avatarReact.ReactNode | nullNo defaultReplaces the default tile. null removes the avatar column.
namestringNo defaultSpeaker name above the content.
status"streaming" | "done" | "error""done"Streaming hides actions; error renders error.
actionsReact.ReactNodeNo defaultUsually MessageActions. Hidden while streaming.
errorReact.ReactNodeNo defaultRendered only when status is error. Usually MessageError.
actionsVisibility"hover" | "always""hover"Use always on the latest turn.
classNamestringNo defaultMerged onto the root.

MessageActions

Copy, regenerate and ratings in a toolbar.

Props of MessageActions
PropTypeDefaultDescription
contentstringNo defaultPlain text to copy. Omit to hide Copy.
onRegenerate() => voidNo defaultShows Regenerate.
feedback"up" | "down" | nullNo defaultControlled rating. Leave undefined for uncontrolled.
onFeedback(value: "up" | "down" | null) => voidNo defaultCalled on every change, including Undo.
childrenReact.ReactNodeNo defaultExtra actions after the built-ins, e.g. BranchPicker.
classNamestringNo defaultMerged onto the toolbar.

BranchPicker

Pages between regenerated versions.

Props of BranchPicker
PropTypeDefaultDescription
indexRequirednumberNo defaultZero-based current version.
countRequirednumberNo defaultNumber of versions. Renders nothing at 1 or less.
onPrevRequired() => voidNo defaultPrevious version.
onNextRequired() => voidNo defaultNext version.
classNamestringNo defaultMerged onto the group.

MessageError

An inline failure with an optional retry.

Props of MessageError
PropTypeDefaultDescription
titlestring"Couldn't finish this response"What didn't happen.
descriptionReact.ReactNodeNo defaultWhy, in plain words.
onRetry() => voidNo defaultShows an outline Retry button.
retryLabelstring"Retry"The retry button's label.
classNamestringNo defaultMerged onto the alert.

AssistantAvatar

The default assistant mark, exported for custom layouts. Also exported: the MessageStatus and MessageFeedback types.

Props of AssistantAvatar
PropTypeDefaultDescription
classNamestringNo defaultMerged 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.