Message actions
A recipe, not a package namespace. The buttons under a chat message are Buttons in a Toolbar inside Message.Actions, and what each one does is up to your product. There is no MessageActions.* export.
On this page
Hover over the question or move focus into it to show its actions, then try copy, edit, regenerate and the ratings.
Give it aria-label, or put a visually hidden label inside it. The icon itself stays hidden from screen readers.
Copy
useClipboard writes the text and announces the result. Copy the message's source text, such as its markdown, not the rendered HTML. The name stays "Copy" while the icon shows a check, so the announcement is what screen readers hear.
import { Button, useClipboard } from '@tounsoo/todae';
import { Check, Copy } from 'lucide-react';
export function CopyButton({ text }: { text: string }) {
const clipboard = useClipboard();
const copied = clipboard.status === 'copied' && clipboard.text === text;
return (
<Button.Root variant="ghost" aria-label="Copy" onClick={() => clipboard.copy(text)}>
<Button.Icon>
{copied ? <Check /> : <Copy />}
</Button.Icon>
</Button.Root>
);
}
Regenerate and rate
Regenerate is a plain button that cancels any reply in flight, asks your backend for a new one and sets streaming on the message until it arrives. Keep the toolbar in place while the reply streams and show Loader's dots after it, so a button that has focus keeps it and no button moves under the pointer. A rating is a single-choice ToggleGroup: pressing the chosen rating again clears it, and onValueChange gets null. Each icon-only button shows its name as a tooltip.
<Message.Root streaming={streaming} aria-label="Assistant">
<Message.Content>…</Message.Content>
<Message.Actions>
<Toolbar.Root aria-label="Reply">
<CopyButton text={reply.markdown} />
<Button.Root variant="ghost" aria-label="Regenerate" onClick={regenerate}>
<Button.Icon>
<RotateCcw />
</Button.Icon>
</Button.Root>
<ToggleGroup.Root aria-label="Rate this reply" value={rating} onValueChange={setRating}>
<ToggleGroup.Item value="good" variant="ghost" aria-label="Good reply">
<Button.Icon>
<ThumbsUp />
</Button.Icon>
</ToggleGroup.Item>
<ToggleGroup.Item value="bad" variant="ghost" aria-label="Bad reply">
<Button.Icon>
<ThumbsDown />
</Button.Icon>
</ToggleGroup.Item>
</ToggleGroup.Root>
</Toolbar.Root>
{streaming && <Loader.Root variant="dots" aria-label="Writing" />}
</Message.Actions>
</Message.Root>
Edit
Edit swaps the message's content and actions for a form with a Field.Textarea, and sends the new text when the form is submitted. When the form closes, put focus back on the edit button, since the button it replaced is gone.
<Message.Root from="user" aria-label="You">
{editing ? (
<form onSubmit={save}>
<Field.Root aria-label="Edit message">
<Field.Textarea value={draft} onChange={(event) => setDraft(event.target.value)} autoGrow />
</Field.Root>
<Button.Root type="button" variant="secondary" onClick={closeEditor}>
Cancel
</Button.Root>
<Button.Root type="submit">Send</Button.Root>
</form>
) : (
<>
<Message.Content>{text}</Message.Content>
<Message.Actions reveal="hover">
<Message.Time value={sentAt} locale={locale} />
<Toolbar.Root aria-label="Your message">
<CopyButton text={text} />
<Button.Root ref={editButton} variant="ghost" aria-label="Edit" onClick={() => setEditing(true)}>
<Button.Icon>
<Pencil />
</Button.Icon>
</Button.Root>
</Toolbar.Root>
</Message.Actions>
</>
)}
</Message.Root>
Accessible composition
Todae handles: Toolbar.Root gives the buttons one tab stop and arrow keys, and a ToggleGroup inside leaves its arrow keys to the toolbar. An icon-only Button.Root shows its aria-label as a tooltip. A ToggleGroup item sets aria-pressed. useClipboard announces whether the copy worked. Message.Actions with reveal="hover" shows its row when focus moves into the message.
You supply: A name for each toolbar and each icon-only button. A name for the edit field. Focus placed back on the edit button when the editor closes. Feedback for an action whose result isn't visible, such as "Thanks for the feedback" through useAnnouncer.
Watch for: Hiding the actions of the latest reply: people use them most, so leave reveal at always there. Removing or disabling the toolbar's buttons while a reply streams: the focused button loses focus to the page. A regenerate that leaves the earlier request running, so the earlier reply can land after the new one.
When to use
When: Actions on one message in a chat with an assistant or a person: copy, regenerate, rate, edit.
When not: Actions on the whole conversation, such as share or delete → a Menu or a Toolbar outside the messages. Sending a new message → Composer.