# Message Actions

> Per-message controls — copy, regenerate, and thumb feedback.

A shadcn/ui block from the [@spectrumui](https://ui.spectrumhq.in) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/spectrumui/message-actions).

- Page, with a live preview: https://shadcn.exchange/r/spectrumui/message-actions
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/spectrumui/message-actions-light-fc15ea13eecd04c9.png
- Registry item: https://ui.spectrumhq.in/r/message-actions.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.spectrumhq.in/r/message-actions.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`

## Files

### `components/spectrumui/blocks/ai-assistants/message-actions.tsx`

```tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { Check, Copy, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { MessageFeedback } from './types';

export type MessageActionsVariant = 'Ghost' | 'Pill';

export interface MessageActionsProps {
  content?: string;
  feedback?: MessageFeedback | null;
  onCopy?: (content: string) => void;
  onRegenerate?: () => void;
  onFeedback?: (feedback: MessageFeedback | null) => void;
  onBranch?: () => void;
  variant?: MessageActionsVariant;
  className?: string;
}

export function MessageActions({
  content = '',
  feedback: feedbackProp,
  onCopy,
  onRegenerate,
  onFeedback,
  onBranch,
  variant = 'Ghost',
  className,
}: MessageActionsProps) {
  const [copied, setCopied] = useState(false);
  const [spinning, setSpinning] = useState(false);
  const [feedbackState, setFeedbackState] = useState<MessageFeedback | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);

  const feedback = feedbackProp !== undefined ? feedbackProp : feedbackState;

  async function copy() {
    try {
      await navigator.clipboard.writeText(content);
    } catch {}
    onCopy?.(content);
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1600);
  }

  function regenerate() {
    setSpinning(true);
    setTimeout(() => setSpinning(false), 500);
    onRegenerate?.();
  }

  function rate(next: MessageFeedback) {
    const value = feedback === next ? null : next;
    setFeedbackState(value);
    onFeedback?.(value);
  }

  return (
    <div
      role="toolbar"
      aria-label="Message actions"
      className={cn(
        'flex w-fit items-center gap-0.5 text-neutral-400 dark:text-neutral-500',
        'opacity-100 transition-opacity duration-150 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/message:opacity-100 [@media(hover:hover)]:focus-within:opacity-100',
        variant === 'Pill' &&
          'rounded-full border border-black/[0.07] bg-white p-0.5 shadow-xs dark:border-white/[0.08] dark:bg-neutral-900',
        className,
      )}
    >
      <ActionButton label={copied ? 'Copied' : 'Copy message'} onClick={copy}>
        <span className="relative grid size-3.5 place-items-center">
          <Copy
            className={cn(
              'absolute size-3.5 transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
              copied ? 'opacity-0 blur-[2px]' : 'opacity-100 blur-0',
            )}
          />
          <Check
            className={cn(
              'absolute size-3.5 text-emerald-600 transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] dark:text-emerald-400',
              copied ? 'opacity-100 blur-0' : 'opacity-0 blur-[2px]',
            )}
          />
        </span>
      </ActionButton>

      <ActionButton label="Regenerate" onClick={regenerate}>
        <RefreshCw
          className={cn(
            'size-3.5 transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]',
            spinning && 'rotate-180',
          )}
        />
      </ActionButton>

      <ActionButton
        label="Good response"
        pressed={feedback === 'positive'}
        onClick={() => rate('positive')}
      >
        <ThumbsUp
          className={cn(
            'size-3.5 transition-[transform,color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
            feedback === 'positive' && 'scale-110 fill-current text-neutral-800 dark:text-neutral-100',
          )}
        />
      </ActionButton>

      <ActionButton
        label="Bad response"
        pressed={feedback === 'negative'}
        onClick={() => rate('negative')}
      >
        <ThumbsDown
          className={cn(
            'size-3.5 transition-[transform,color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',
            feedback === 'negative' && 'scale-110 fill-current text-neutral-800 dark:text-neutral-100',
          )}
        />
      </ActionButton>

      {onBranch && (
        <ActionButton label="Branch from here" onClick={onBranch}>
          <GitBranch className="size-3.5" />
        </ActionButton>
      )}
    </div>
  );
}

function ActionButton({
  label,
  pressed,
  onClick,
  children,
}: {
  label: string;
  pressed?: boolean;
  onClick?: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={label}
      aria-pressed={pressed}
      onClick={onClick}
      className="grid size-7 place-items-center rounded-md transition-[color,background-color,transform] duration-150 hover:bg-black/[0.04] hover:text-neutral-700 active:scale-[0.92] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-neutral-200"
    >
      {children}
    </button>
  );
}

export default MessageActions;
```

### `components/spectrumui/blocks/ai-assistants/types.ts`

```ts
export type MessageRole = 'user' | 'assistant' | 'system';

export type MessageState = 'streaming' | 'complete' | 'error';

export interface Attachment {
  id: string;
  name: string;
  size: number;
  type: string;
  url?: string;
  previewUrl?: string;
}

export interface Citation {
  id: string;
  index: number;
  url: string;
  title: string;
  snippet?: string;
  favicon?: string;
}

export type ToolCallStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled';

export interface ToolCall {
  id: string;
  name: string;
  args?: Record<string, unknown>;
  result?: string;
  status: ToolCallStatus;
  startedAt?: number;
  completedAt?: number;
  children?: ToolCall[];
  parallel?: boolean;
}

export interface ReasoningStep {
  id: string;
  content: string;
}

export interface Reasoning {
  steps: ReasoningStep[];
  status: 'thinking' | 'complete';
  durationMs?: number;
}

export interface Message {
  id: string;
  role: MessageRole;
  content: string;
  createdAt?: number;
  state?: MessageState;
  attachments?: Attachment[];
  citations?: Citation[];
  toolCalls?: ToolCall[];
  reasoning?: Reasoning;
}

export interface Conversation {
  id: string;
  label: string;
}

export interface ModelOption {
  id: string;
  name: string;
  description?: string;
  badge?: string;
  disabled?: boolean;
}

export interface SuggestedPrompt {
  id: string;
  label: string;
  prompt?: string;
}

export type MessageFeedback = 'positive' | 'negative';

export interface UsageState {
  promptTokens: number;
  completionTokens: number;
  contextWindow: number;
  estimatedCostUsd?: number;
}
```
