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
askReasonOnand chip lists. - Prefer a plain icon row when you only need regenerate / share later — add custom
MessageActionchildren 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 isuseClipboardCopy.
Interactions
| Event | Behavior |
|---|---|
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 click | Updates selection immediately; mutual exclusive; re-click clears to null |
| Reason popover | Opens when the selected side is included in askReasonOn (default down) |
| Popover Submit | Fires onFeedbackSubmit with value + reason; keeps selection |
| Popover dismiss | Fires 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-actionsFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/message-actionsFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/message-actionsFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/message-actionsInstall 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.
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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the row (role="group"). |
children | ReactNode | — | Copy, Feedback, and optional custom actions. |
MessageAction
| Prop | Type | Default | Description |
|---|---|---|---|
tooltip | string | — | Visible tooltip (required for icon-only). |
label | string | tooltip | Screen-reader label. |
… | Button props | — | Forwards onClick, disabled, aria-pressed, etc. |
MessageCopyAction
| Prop | Type | Default | Description |
|---|---|---|---|
copyText | string | — | Text written via useClipboardCopy. |
onCopy | (text: string | undefined) => void | — | With copyText, fires after a successful write; without copyText, fires on click with undefined (host owns clipboard). |
onCopyError | (error: unknown) => void | — | Clipboard failure. |
disabled | boolean | — | Disables the control. |
Renders nothing when neither copyText nor onCopy is set.
MessageFeedback
| Prop | Type | Default | Description |
|---|---|---|---|
value / onValueChange | "up" | "down" | null / (next) => void | — | Controlled selection. |
defaultValue | "up" | "down" | null | null | Uncontrolled initial value. |
askReasonOn | "none" | "up" | "down" | "both" | "down" | Which thumb opens the reason popover. |
reasons | { id, label }[] | built-in | Shared chip list for both sides when set. |
reasonsUp / reasonsDown | { id, label }[] | — | Per-side override; wins over reasons / defaults. |
onFeedbackSubmit | (payload) => void | — | Popover Submit only (value + reason). |
onFeedbackCancel | () => void | — | Popover dismissed without Submit. |
disabled | boolean | — | Disables both thumbs. |
Slots
Parts: MessageActions, MessageAction, MessageCopyAction, MessageFeedback, FeedbackReasonPopover. Style via each part’s className.