Custom Actions
Add custom action buttons to messages, like your own feedback or "send to CRM" buttons.
New in 0.4.0
Copy, regenerate, edit and thumbs up / down with reasons are now built in: switch them on with the messageActions and feedback props (see Portal Experience). Write your own actions with renderMessageActions only for what the built-in row does not cover.
Built-in or custom
| You want | Use |
|---|---|
| Copy, regenerate the newest answer, edit-and-resend your own messages | messageActions={{ copy: true, regenerate: true, edit: true }} |
| Thumbs up / down with reasons and a comment, stored by the server | feedback={{ enabled: true }} (and onFeedback) |
| Something of your own: save to CRM, create a ticket, share | renderMessageActions |
The built-in row and your own slot can be used together.
Render Message Actions
renderMessageActions is drawn under every finished assistant message, hidden until the message is hovered (opacity-0 group-hover:opacity-100). It is not called for the message that is still streaming.
import { Chat, ThumbsUpIcon, ThumbsDownIcon } from "@cognipeer/chat-ui";
function MessageActions({ message, isStreaming }) {
// Don't show actions while streaming
if (isStreaming) return null;
const handleFeedback = (type: "up" | "down") => {
// Send to your API
fetch("/api/feedback", {
method: "POST",
body: JSON.stringify({ messageId: message.id, feedback: type }),
});
};
return (
<div className="flex gap-2">
<button
type="button"
onClick={() => handleFeedback("up")}
className="p-1 hover:bg-gray-700 rounded"
title="Good response"
aria-label="Good response"
>
<ThumbsUpIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleFeedback("down")}
className="p-1 hover:bg-gray-700 rounded"
title="Bad response"
aria-label="Bad response"
>
<ThumbsDownIcon className="w-4 h-4" />
</button>
</div>
);
}
<Chat
baseUrl="/api/agents"
agentId="assistant"
renderMessageActions={(props) => <MessageActions {...props} />}
/>The function receives:
interface MessageActionProps {
message: Message;
isStreaming: boolean;
}Buttons need accessible names (aria-label) because the slot only holds icons; keep them keyboard-reachable (real button elements).
Available Icons
Every icon the chat uses is exported, so your buttons match the built-in ones:
import {
ThumbsUpIcon, ThumbsDownIcon, CopyIcon, RefreshIcon, PencilIcon, TrashIcon,
CheckIcon, XIcon, SendIcon, StopIcon, PaperclipIcon, FileIcon, ImageIcon,
ChatIcon, PlusIcon, MenuIcon, ToolIcon, AlertIcon, InfoIcon, LoadingIcon,
ChevronDownIcon, ChevronRightIcon, PinIcon, DownloadIcon, BellIcon, ListCheckIcon,
} from "@cognipeer/chat-ui";Pre-built actions
MessageActions is a ready-made row (copy, regenerate, thumbs) you can drop into renderMessageActions:
import { Chat, MessageActions } from "@cognipeer/chat-ui";
<Chat
baseUrl="/api/agents"
renderMessageActions={(props) => (
<MessageActions
{...props}
showRegenerate={false}
onFeedback={(messageId, type) => save(messageId, type)} // type: "positive" | "negative"
onCopy={(content) => analytics.track("copy")}
/>
)}
/>It shows nothing for user messages and while streaming. Its feedback state is local to the row (it does not talk to the server); for server-stored ratings with reasons use the built-in feedback prop.
Copy Button
import { copyText } from "@cognipeer/chat-ui";
function CopyAction({ message }) {
const text = typeof message.content === "string"
? message.content
: message.content.map((p) => (p.type === "text" ? p.text : "")).join("");
return (
<button type="button" aria-label="Copy" onClick={() => copyText(text)}>
<CopyIcon className="w-4 h-4" />
</button>
);
}copyText uses the clipboard API and falls back to the older copy command inside an iframe that has no allow="clipboard-write"; it resolves false when neither worked.
Regenerate Button
Regenerating the newest answer is built in (messageActions.regenerate). In a custom layout built on useChat:
function RegenerateAction() {
const { regenerate, canRegenerate, isStreaming } = chat; // from useChat(...)
if (!canRegenerate) return null;
return (
<button type="button" disabled={isStreaming} onClick={() => regenerate()}>
<RefreshIcon className="w-4 h-4" />
</button>
);
}retry() sends the last message again after an error; regenerate() runs the newest answer again from the message that asked for it (POST /conversations/:id/messages/:messageId/regenerate), and resolves false when it cannot (a turn runs, a card waits, the last message is not an answer).
Feedback with State
Track the rating yourself, or let the library do it: with the built-in feedback prop a stored rating comes back on message.feedback (also read from metadata.feedback) and the thumb shows pressed.
import { useState } from "react";
function FeedbackActions({ message }) {
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
const handleFeedback = async (type: "up" | "down") => {
const next = feedback === type ? null : type; // pressing the same thumb again withdraws it
setFeedback(next);
await submitFeedback(message.id, next);
};
return (
<div className="flex gap-1">
<button type="button" aria-pressed={feedback === "up"} onClick={() => handleFeedback("up")}>
<ThumbsUpIcon className="w-4 h-4" />
</button>
<button type="button" aria-pressed={feedback === "down"} onClick={() => handleFeedback("down")}>
<ThumbsDownIcon className="w-4 h-4" />
</button>
</div>
);
}For the server-backed version use useChat().submitFeedback(messageId, { rating, reasons, comment }); it shows the rating at once and takes it back if the server refuses.
Styling Actions
The built-in row uses the .chat-message-actions class: invisible until the message is hovered or holds keyboard focus, always visible under the newest message and on touch screens. Your own slot is wrapped in a hover fade; style your buttons with the theme variables:
.my-action {
padding: 4px;
border-radius: 4px;
color: var(--chat-text-tertiary);
}
.my-action:hover {
background: var(--chat-bg-hover);
color: var(--chat-text-primary);
}
