Elements

Message actions

Copy, rate, and regenerate. Each action confirms itself with a small state change.

fig. 01

Installation

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 init

Then 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.

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

components/assistant-ui/elements/message-actions.tsx
"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 exact more menu on every assistant message; it does not include the feedback buttons above, so add FeedbackPositive/FeedbackNegative next to Thread's row if you need rating.

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) and tints emerald while copied is true; the regenerate icon spins while regenerating is true. At runtime, ActionBarPrimitive.FeedbackPositive/FeedbackNegative are also 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);

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

PartRendersNotes
ActionBarPrimitive.RootdivhideWhenRunning, autohide, and autohideFloat control visibility; unset, the row always renders.
ActionBarPrimitive.CopybuttonDisabled once there's no text left to copy. copiedDuration (default 3000) controls how long data-copied stays set.
ActionBarPrimitive.FeedbackPositivebuttonCalls aui.message.submitFeedback({ type: "positive" }).
ActionBarPrimitive.FeedbackNegativebuttonCalls aui.message.submitFeedback({ type: "negative" }).
ActionBarPrimitive.ReloadbuttonDisabled while the thread is running, disabled, or the message isn't from the assistant.
ActionBarPrimitive.ExportMarkdownbuttonCopies or downloads the message as Markdown; drop it inside ActionBarMorePrimitive.Item.
ActionBarMorePrimitive.Rootcontext onlyA Radix DropdownMenu.Root scoped to the action bar's interaction lock.
ActionBarMorePrimitive.Trigger / .Content / .Item / .Separatorbutton / div / div / divStandard dropdown menu parts.

Message state

SelectorTypeDescription
s.message.isCopiedbooleanTrue for copiedDuration after ActionBarPrimitive.Copy succeeds.
s.message.metadata.submittedFeedback?.type"positive" | "negative" | undefinedThe last feedback submitted for this message, if any.
aui.message.submitFeedback({ type })(feedback: { type: "positive" | "negative" }) => voidRecords a reaction. There is no method to clear one.
aui.message.reload(config?)(config?: { runConfig?: RunConfig }) => voidRegenerates this assistant message as a new sibling branch.