f-ui
Components

Message Actions

Always-visible chat turn actions — copy and thumbs feedback with an optional reason popover.

Plus Registry

This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/message-actions.

Message Actions is the always-visible action row under a completed assistant turn — Copy and thumbs up / down feedback with an optional reason popover. Compose it inside Message MessageFooter. Clipboard writes go through useClipboardCopy from Copy Affordance; do not wrap markdown in Copy Affordance for turn copy — use MessageCopyAction.

When To Use

  • Show Copy and thumbs under a finished assistant message so operators can find feedback without hover discovery.
  • Capture structured dislike (or like) reasons via askReasonOn and chip lists.
  • Prefer a plain icon row when you only need regenerate / share later — add custom MessageAction children beside Copy / Feedback.
  • Omit the whole tree while the turn is still streaming; the host owns that gate.
  • Do not wrap the message body in Copy Affordance for turn copy — that shell is for field / ID values. Use MessageCopyAction. Clipboard behavior is useClipboardCopy.

Interactions

EventBehavior
Copy (copyText)Writes to the clipboard via useClipboardCopy; icon flashes Check ~2s
Copy (onCopy only)Calls the host callback; no built-in clipboard write
Thumbs clickUpdates selection immediately; mutual exclusive; re-click clears to null
Reason popoverOpens when the selected side is included in askReasonOn (default down)
Popover SubmitFires onFeedbackSubmit with value + reason; keeps selection
Popover dismissFires onFeedbackCancel; does not revert the thumb

Installing

Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.

FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/message-actions
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/message-actions
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/message-actions
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/message-actions

Install URL: https://ui.isaacfei.com/api/plus/r/message-actions.json.

registryDependencies: button, tooltip, popover, textarea, https://ui.isaacfei.com/r/copy-affordance.json, https://ui.isaacfei.com/r/fui-i18n.json. Runtime npm: lucide-react. Labels use built-in bundles via I18nProvider.

Usage

import {
  MessageActions,
  MessageCopyAction,
  MessageFeedback,
} from "@/components/f-ui/message-actions/message-actions";
import {
  Message,
  MessageContent,
  MessageFooter,
} from "@/components/f-ui/message/message";
import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";

<Message align="start">
  <MessageContent>
    <MarkdownRenderer source={text} />
    <MessageFooter>
      <MessageActions>
        <MessageCopyAction copyText={text} />
        <MessageFeedback
          askReasonOn="down"
          onValueChange={setValue}
          onFeedbackSubmit={handleSubmit}
        />
      </MessageActions>
    </MessageFooter>
  </MessageContent>
</Message>

Examples

Copy and Thumbs Down Reason

Default askReasonOn="down": copy the reply, then thumbs down to open the reason popover. Thumbs up selects without asking for a reason.

Assistant

Here is a short assistant reply you can copy or rate. Thumbs down opens a reason popover by default.

value: null · last submit:

"use client";

import { useState } from "react";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";
import {
  MessageActions,
  MessageCopyAction,
  MessageFeedback,
  type MessageFeedbackSubmitPayload,
  type MessageFeedbackValue,
} from "@/components/f-ui/message-actions/message-actions";
import {
  Message,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/components/f-ui/message/message";

const TEXT =
  "Here is a short assistant reply you can **copy** or rate. Thumbs down opens a reason popover by default.";

/**
 * Default assistant footer: Copy + Feedback with askReasonOn="down".
 */
export function MessageActionsDemo() {
  const [value, setValue] = useState<MessageFeedbackValue>(null);
  const [lastSubmit, setLastSubmit] = useState<string>("—");

  function handleSubmit(payload: MessageFeedbackSubmitPayload) {
    setLastSubmit(
      `${payload.value}: [${payload.reason.reasonIds.join(", ")}]${
        payload.reason.comment ? ` — ${payload.reason.comment}` : ""
      }`,
    );
  }

  return (
    <div className="flex flex-col gap-3">
      <Message align="start">
        <MessageContent>
          <MessageHeader>Assistant</MessageHeader>
          <MarkdownRenderer
            className="prose-sm dark:prose-invert max-w-none"
            source={TEXT}
          />
          <MessageFooter>
            <MessageActions>
              <MessageCopyAction copyText={TEXT} />
              <MessageFeedback
                value={value}
                onValueChange={setValue}
                askReasonOn="down"
                onFeedbackSubmit={handleSubmit}
              />
            </MessageActions>
          </MessageFooter>
        </MessageContent>
      </Message>
      <p className="text-muted-foreground text-xs">
        value: {value ?? "null"} · last submit: {lastSubmit}
      </p>
    </div>
  );
}

Ask Reason on Both Sides

Set askReasonOn="both" and pass a shared reasons list so either thumb opens the same custom chips.

Assistant

Rate this reply either way — both thumbs open a reason popover with custom chips.

value: null · last submit:

"use client";

import { useState } from "react";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";
import {
  MessageActions,
  MessageCopyAction,
  MessageFeedback,
  type MessageFeedbackReasonOption,
  type MessageFeedbackSubmitPayload,
  type MessageFeedbackValue,
} from "@/components/f-ui/message-actions/message-actions";
import {
  Message,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/components/f-ui/message/message";

const TEXT =
  "Rate this reply either way — both thumbs open a reason popover with custom chips.";

const CUSTOM_REASONS: MessageFeedbackReasonOption[] = [
  { id: "tone", label: "Tone" },
  { id: "length", label: "Length" },
  { id: "other", label: "Other" },
];

/**
 * askReasonOn="both" with a shared custom reasons list.
 */
export function MessageActionsAskBothDemo() {
  const [value, setValue] = useState<MessageFeedbackValue>(null);
  const [lastSubmit, setLastSubmit] = useState<string>("—");

  function handleSubmit(payload: MessageFeedbackSubmitPayload) {
    setLastSubmit(
      `${payload.value}: [${payload.reason.reasonIds.join(", ")}]${
        payload.reason.comment ? ` — ${payload.reason.comment}` : ""
      }`,
    );
  }

  return (
    <div className="flex flex-col gap-3">
      <Message align="start">
        <MessageContent>
          <MessageHeader>Assistant</MessageHeader>
          <MarkdownRenderer
            className="prose-sm dark:prose-invert max-w-none"
            source={TEXT}
          />
          <MessageFooter>
            <MessageActions>
              <MessageCopyAction copyText={TEXT} />
              <MessageFeedback
                value={value}
                onValueChange={setValue}
                askReasonOn="both"
                reasons={CUSTOM_REASONS}
                onFeedbackSubmit={handleSubmit}
              />
            </MessageActions>
          </MessageFooter>
        </MessageContent>
      </Message>
      <p className="text-muted-foreground text-xs">
        value: {value ?? "null"} · last submit: {lastSubmit}
      </p>
    </div>
  );
}

Host-Owned Copy

Omit copyText and pass onCopy when the host owns clipboard / analytics. The button still renders; Feedback here uses askReasonOn="none" so thumbs only update value.

Assistant

Host-owned copy: the toolbar button calls onCopy only — no built-in clipboard write.

value: null · copy:

"use client";

import { useState } from "react";

import { MarkdownRenderer } from "@/components/f-ui/markdown-renderer/markdown-renderer";
import {
  MessageActions,
  MessageCopyAction,
  MessageFeedback,
  type MessageFeedbackValue,
} from "@/components/f-ui/message-actions/message-actions";
import {
  Message,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/components/f-ui/message/message";

const TEXT =
  "Host-owned copy: the toolbar button calls `onCopy` only — no built-in clipboard write.";

/**
 * MessageCopyAction with onCopy only (no copyText) for host clipboard / analytics.
 */
export function MessageActionsHostCopyDemo() {
  const [value, setValue] = useState<MessageFeedbackValue>(null);
  const [copyLog, setCopyLog] = useState<string>("—");

  return (
    <div className="flex flex-col gap-3">
      <Message align="start">
        <MessageContent>
          <MessageHeader>Assistant</MessageHeader>
          <MarkdownRenderer
            className="prose-sm dark:prose-invert max-w-none"
            source={TEXT}
          />
          <MessageFooter>
            <MessageActions>
              <MessageCopyAction
                onCopy={() => {
                  setCopyLog(`host onCopy at ${new Date().toLocaleTimeString()}`);
                }}
              />
              <MessageFeedback
                value={value}
                onValueChange={setValue}
                askReasonOn="none"
              />
            </MessageActions>
          </MessageFooter>
        </MessageContent>
      </Message>
      <p className="text-muted-foreground text-xs">
        value: {value ?? "null"} · copy: {copyLog}
      </p>
    </div>
  );
}

Composition

Message (align="start")
└── MessageContent
    ├── MarkdownRenderer | host body
    └── MessageFooter
        └── MessageActions
            ├── MessageCopyAction?
            ├── MessageFeedback?
            │   ├── MessageAction (up)
            │   ├── MessageAction (down)
            │   └── FeedbackReasonPopover
            └── MessageAction?          (host custom actions)

API Reference

Props

MessageActions

PropTypeDefaultDescription
classNamestringMerged onto the row (role="group").
childrenReactNodeCopy, Feedback, and optional custom actions.

MessageAction

PropTypeDefaultDescription
tooltipstringVisible tooltip (required for icon-only).
labelstringtooltipScreen-reader label.
Button propsForwards onClick, disabled, aria-pressed, etc.

MessageCopyAction

PropTypeDefaultDescription
copyTextstringText written via useClipboardCopy.
onCopy(text: string | undefined) => voidWith copyText, fires after a successful write; without copyText, fires on click with undefined (host owns clipboard).
onCopyError(error: unknown) => voidClipboard failure.
disabledbooleanDisables the control.

Renders nothing when neither copyText nor onCopy is set.

MessageFeedback

PropTypeDefaultDescription
value / onValueChange"up" | "down" | null / (next) => voidControlled selection.
defaultValue"up" | "down" | nullnullUncontrolled initial value.
askReasonOn"none" | "up" | "down" | "both""down"Which thumb opens the reason popover.
reasons{ id, label }[]built-inShared chip list for both sides when set.
reasonsUp / reasonsDown{ id, label }[]Per-side override; wins over reasons / defaults.
onFeedbackSubmit(payload) => voidPopover Submit only (value + reason).
onFeedbackCancel() => voidPopover dismissed without Submit.
disabledbooleanDisables both thumbs.

Slots

Parts: MessageActions, MessageAction, MessageCopyAction, MessageFeedback, FeedbackReasonPopover. Style via each part’s className.

On this page