Message actions
Copy, rate, and regenerate. Each action confirms itself with a small state change.
Installation
Install manually
npx shadcn@latest add "@assistant-ui/elements-message-actions"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-message-actions"Props-driven: no runtime or provider required.
The action row under an assistant reply: copy, thumbs up or down, regenerate, and a catch-all more button. Each action confirms its own outcome in place rather than with a toast: copy swaps to a check mark, a rating fills in, regenerate spins while it runs. With a runtime every button calls straight into the thread; standalone you hold the state each button reflects and the callbacks it fires.
Getting started
ActionBarPrimitive supplies one button per action, each already wired to the message it renders inside and disabled the moment that action stops being available.
Compose the row
"use client";
import { ActionBarPrimitive, AuiIf } from "@assistant-ui/react";
import {
CheckIcon,
CopyIcon,
RefreshCwIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ghostButton } from "@/components/assistant-ui/elements/surfaces";
export function MessageActions() {
const buttonClassName = cn(ghostButton, "size-7");
return (
<ActionBarPrimitive.Root className="flex items-center gap-1">
<ActionBarPrimitive.Copy aria-label="Copy response" className={buttonClassName}>
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon className="size-3.5" />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon className="size-3.5" />
</AuiIf>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.FeedbackPositive aria-label="Mark response helpful" className={buttonClassName}>
<ThumbsUpIcon className="size-3.5" />
</ActionBarPrimitive.FeedbackPositive>
<ActionBarPrimitive.FeedbackNegative aria-label="Mark response unhelpful" className={buttonClassName}>
<ThumbsDownIcon className="size-3.5" />
</ActionBarPrimitive.FeedbackNegative>
<ActionBarPrimitive.Reload aria-label="Regenerate response" className={buttonClassName}>
<RefreshCwIcon className="size-3.5" />
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
);
}ActionBarPrimitive.Copy sets data-copied="true" for three seconds after a successful copy (tune it with copiedDuration), and ActionBarPrimitive.FeedbackPositive/FeedbackNegative set data-submitted="true" once s.message.metadata.submittedFeedback matches. Style off those attributes instead of holding your own booleans.
Add a more menu
ActionBarMorePrimitive is a Radix dropdown menu scoped to the same message; ActionBarPrimitive.ExportMarkdown is a ready-made item for it.
import { ActionBarMorePrimitive, ActionBarPrimitive } from "@assistant-ui/react";
import { DownloadIcon, EllipsisIcon } from "lucide-react";
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger aria-label="More response actions" className={buttonClassName}>
<EllipsisIcon className="size-3.5" />
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content side="bottom" align="start" className="bg-popover rounded-xl border p-1.5">
<ActionBarPrimitive.ExportMarkdown asChild>
<ActionBarMorePrimitive.Item className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm">
<DownloadIcon className="size-4" />
Export as Markdown
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>The Thread element ships copy, reload, and this more menu on every assistant message. It renders FeedbackPositive/FeedbackNegative only when s.thread.capabilities.feedback is true.
Standalone, MessageActions is fully controlled: five callbacks and the pieces of state that decide how each button currently looks.
Hold the row's state
Install the copy hook from the registry:
npx shadcn@latest add "@assistant-ui/use-copy-to-clipboard""use client";
import { useState } from "react";
import { MessageActions, type Reaction } from "@/components/assistant-ui/elements/message-actions";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
export function Reply() {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const [reaction, setReaction] = useState<Reaction>(null);
const [regenerating, setRegenerating] = useState(false);
return (
<MessageActions
copied={isCopied}
reaction={reaction}
regenerating={regenerating}
onCopy={() => copyToClipboard("...")}
onReactionChange={setReaction}
onRegenerate={async () => {
setRegenerating(true);
await regenerate();
setRegenerating(false);
}}
onMore={() => {}}
/>
);
}Open your own menu from onMore
onMore fires on click with no built-in menu attached; open a popover or dropdown from your own component tree when it's called.
Anatomy
<div data-slot="message-actions">
<button aria-label="Copy response" />
<button aria-label="Mark response helpful" aria-pressed={false} />
<button aria-label="Mark response unhelpful" aria-pressed={false} />
<button aria-label="Regenerate response" />
<button aria-label="More response actions" />
</div>Standalone, the two rating buttons are aria-pressed toggles and mutually exclusive: pressing the active one again calls onReactionChange(null) to clear it, and pressing the other switches straight over. The copy button swaps its icon (iconSwap/iconSwapIn/iconSwapOut), tints emerald, and changes its label to Copied response while copied is true; the regenerate icon spins while regenerating is true. At runtime, ActionBarPrimitive.FeedbackPositive/FeedbackNegative are also aria-pressed toggles and mutually exclusive (submitting one replaces the other in s.message.metadata.submittedFeedback), but there is no clear: MessageMethods.submitFeedback only ever writes a reaction, so once one is submitted a message has no built-in path back to "no reaction" the way the standalone toggle does.
Examples
Copy confirmation
The copied state and its timer live inside ActionBarPrimitive.Copy; there's nothing for you to reset. Read s.message.isCopied directly if you need the same confirmation elsewhere in the row.
const isCopied = useAuiState((s) => s.message.isCopied);The copied state and its timer live inside useCopyToClipboard; there's nothing for you to reset. isCopied turns true only once the clipboard write succeeds and stays true for three seconds after the latest successful copy (tune it with useCopyToClipboard({ copiedDuration })).
Restyle the row
Both lanes take className on the root, and the icon-swap tokens (iconSwap, iconSwapIn, iconSwapOut) and ghostButton from surfaces.tsx cover the copy transition and every button's hover state.
<MessageActions className="gap-2" /* ... */ />API reference
ActionBarPrimitive and ActionBarMorePrimitive
| Part | Renders | Notes |
|---|---|---|
ActionBarPrimitive.Root | div | hideWhenRunning, autohide, and autohideFloat control visibility; unset, the row always renders. |
ActionBarPrimitive.Copy | button | Disabled once there's no text left to copy. copiedDuration (default 3000) controls how long data-copied stays set. |
ActionBarPrimitive.FeedbackPositive | button | Calls aui.message.submitFeedback({ type: "positive" }). |
ActionBarPrimitive.FeedbackNegative | button | Calls aui.message.submitFeedback({ type: "negative" }). |
ActionBarPrimitive.Reload | button | Disabled while the thread is running, disabled, or the message isn't from the assistant. |
ActionBarPrimitive.ExportMarkdown | button | Downloads the message as a Markdown file, or hands the content to onExport when you pass one; wrap an ActionBarMorePrimitive.Item in it with asChild. |
ActionBarMorePrimitive.Root | context only | A Radix DropdownMenu.Root scoped to the action bar's interaction lock. |
ActionBarMorePrimitive.Trigger / .Content / .Item / .Separator | button / div / div / div | Standard dropdown menu parts. |
Message state
| Selector | Type | Description |
|---|---|---|
s.message.isCopied | boolean | True for copiedDuration after ActionBarPrimitive.Copy succeeds. |
s.message.metadata.submittedFeedback?.type | "positive" | "negative" | undefined | The last feedback submitted for this message, if any. |
aui.message.submitFeedback({ type }) | (feedback: { type: "positive" | "negative"; comment?: string }) => void | Records a reaction. There is no method to clear one. |
aui.message.reload(config?) | (config?: { runConfig?: RunConfig }) => void | Regenerates this assistant message as a new sibling branch. |
MessageActions
| Prop | Type | Default | Description |
|---|---|---|---|
copied | boolean | required | Shows the check mark and emerald tint on the copy button. |
reaction | "up" | "down" | null | required | Which rating button, if any, reads as pressed. |
regenerating | boolean | required | Spins the regenerate icon. |
onCopy | () => void | required | Called when the copy button is pressed. |
onReactionChange | (reaction: "up" | "down" | null) => void | required | Called with the next reaction; null when the active one is pressed again. |
onRegenerate | () => void | required | Called when the regenerate button is pressed. |
onMore | () => void | required | Called when the more button is pressed. |
className | string | Merged onto the root. |
All other div props are forwarded to the root.