Elements

08 / 37 · Messages

Message actions

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

Installation

npx shadcn@latest add "@assistant-ui/elements-message-actions"

Usage

import { MessageActions } from "@/components/elements/message-actions";

<MessageActions
  copied={false}
  reaction={null}
  regenerating={false}
  onCopy={() => {}}
  onReactionChange={setReaction}
  onRegenerate={() => {}}
  onMore={() => {}}
/>

Props

copied*booleanWhen true the copy button shows a green check confirmation.
reaction*ReactionActive thumbs reaction, or null when none is selected.
regenerating*booleanWhen true the regenerate icon spins to show work in progress.
onCopy*() => voidCalled when the copy control is pressed.
onReactionChange*(reaction: Reaction) => voidCalled when the user toggles thumbs up or thumbs down.
onRegenerate*() => voidCalled when the regenerate control is pressed.
onMore*() => voidCalled when the overflow menu control is pressed.
classNamestringExtra classes merged onto the root.

Source

message-actions.tsx
"use client";

import {
  CheckIcon,
  CopyIcon,
  EllipsisIcon,
  RefreshCwIcon,
  ThumbsDownIcon,
  ThumbsUpIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ghostButton, iconSwap, iconSwapIn, iconSwapOut } from "./surfaces";

export type Reaction = "up" | "down" | null;

export interface MessageActionsProps {
  copied: boolean;
  reaction: Reaction;
  regenerating: boolean;
  onCopy: () => void;
  onReactionChange: (reaction: Reaction) => void;
  onRegenerate: () => void;
  onMore: () => void;
  className?: string;
}

export function MessageActions({
  copied,
  reaction,
  regenerating,
  onCopy,
  onReactionChange,
  onRegenerate,
  onMore,
  className,
}: MessageActionsProps) {
  const buttonClassName = cn(ghostButton, "size-7");

  return (
    <div className={cn("flex items-center gap-1", className)}>
      <button
        type="button"
        aria-label={copied ? "Copied response" : "Copy response"}
        onClick={onCopy}
        className={cn(
          buttonClassName,
          "grid place-items-center",
          copied && "text-emerald-500",
        )}
      >
        <CopyIcon
          className={cn(
            iconSwap,
            "size-3.5",
            copied ? iconSwapOut : iconSwapIn,
          )}
        />
        <CheckIcon
          className={cn(
            iconSwap,
            "size-3.5",
            copied ? iconSwapIn : iconSwapOut,
          )}
        />
      </button>
      <button
        type="button"
        aria-label="Mark response helpful"
        aria-pressed={reaction === "up"}
        onClick={() => onReactionChange(reaction === "up" ? null : "up")}
        className={cn(
          buttonClassName,
          reaction === "up" &&
            "bg-foreground/[0.06] text-foreground/90 dark:bg-foreground/[0.09]",
        )}
      >
        <ThumbsUpIcon className="size-3.5" />
      </button>
      <button
        type="button"
        aria-label="Mark response unhelpful"
        aria-pressed={reaction === "down"}
        onClick={() => onReactionChange(reaction === "down" ? null : "down")}
        className={cn(
          buttonClassName,
          reaction === "down" &&
            "bg-foreground/[0.06] text-foreground/90 dark:bg-foreground/[0.09]",
        )}
      >
        <ThumbsDownIcon className="size-3.5" />
      </button>
      <button
        type="button"
        aria-label="Regenerate response"
        onClick={onRegenerate}
        className={buttonClassName}
      >
        <RefreshCwIcon
          className={cn(
            "size-3.5",
            regenerating && "animate-spin motion-reduce:animate-none",
          )}
        />
      </button>
      <button
        type="button"
        aria-label="More response actions"
        onClick={onMore}
        className={buttonClassName}
      >
        <EllipsisIcon className="size-3.5" />
      </button>
    </div>
  );
}