"use client"; import { Copy, MoreHorizontal, RotateCw, Share, ThumbsDown, ThumbsUp, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ComponentPropsWithRef, type ReactNode, useEffect, useRef, useState, } from "react"; import { Button } from "@/components/motion/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/motion/dropdown-menu"; import { MorphPopover, MorphPopoverContent, } from "@/components/motion/popover-morph"; import { Textarea } from "@/components/motion/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/motion/toggle-group"; import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type MessageFeedback = "up" | "down" | null; export interface MessageFeedbackReason { value: string; label: string; } export interface MessageFeedbackDetails { reasons: string[]; comment: string; } export interface MessageMoreAction { value: string; label: string; icon?: ReactNode; destructive?: boolean; disabled?: boolean; } export interface MessageActionsProps extends Omit, "children"> { /** Plain text copied by the copy action. */ copyText?: string; /** Overrides the built-in clipboard write. */ onCopy?: () => void | Promise; /** Shows the regenerate action. A returned promise keeps the icon spinning. */ onRegenerate?: () => void | Promise; feedback?: MessageFeedback; defaultFeedback?: MessageFeedback; onFeedbackChange?: (feedback: MessageFeedback) => void; /** Reasons offered after a thumbs-down. Pass `[]` to skip the popover. */ feedbackReasons?: MessageFeedbackReason[]; onFeedbackSubmit?: (details: MessageFeedbackDetails) => void; onShare?: () => void; moreActions?: MessageMoreAction[]; onMoreAction?: (value: string) => void; /** * `hover` reveals the row while the surrounding `Message` / message group is * hovered or focused (always visible on touch); `always` keeps it shown — * use it for the last message. */ visibility?: "hover" | "always"; } export const DEFAULT_FEEDBACK_REASONS: MessageFeedbackReason[] = [ { value: "incorrect", label: "Incorrect" }, { value: "unhelpful", label: "Not helpful" }, { value: "incomplete", label: "Incomplete" }, { value: "instructions", label: "Ignored instructions" }, { value: "unsafe", label: "Harmful" }, ]; function ActionButton({ label, pressed, onClick, children, className, }: { label: string; pressed?: boolean; onClick?: () => void; children: ReactNode; className?: string; }) { const reduce = useReducedMotion() ?? false; return ( {children} ); } function DrawnCheck({ reduce }: { reduce: boolean }) { return ( ); } /** Outline thumb with a filled twin that pops in over it when active. */ function Thumb({ direction, active, reduce, }: { direction: "up" | "down"; active: boolean; reduce: boolean; }) { const Icon = direction === "up" ? ThumbsUp : ThumbsDown; return ( ); } export function MessageActions({ copyText, onCopy, onRegenerate, feedback, defaultFeedback = null, onFeedbackChange, feedbackReasons = DEFAULT_FEEDBACK_REASONS, onFeedbackSubmit, onShare, moreActions = [], onMoreAction, visibility = "hover", className, ...props }: MessageActionsProps) { const reduce = useReducedMotion() ?? false; const [copied, setCopied] = useState(false); const [spins, setSpins] = useState(0); const [regenerating, setRegenerating] = useState(false); const [internalFeedback, setInternalFeedback] = useState(defaultFeedback); const [popoverOpen, setPopoverOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [reasons, setReasons] = useState([]); const [comment, setComment] = useState(""); const [thanked, setThanked] = useState(false); const [announcement, setAnnouncement] = useState(""); const copyTimer = useRef(undefined); const thanksTimer = useRef(undefined); const downRef = useRef(null); const panelRef = useRef(null); const current = feedback ?? internalFeedback; const canCopy = Boolean(copyText || onCopy); useEffect( () => () => { window.clearTimeout(copyTimer.current); window.clearTimeout(thanksTimer.current); }, [], ); const copy = async () => { try { if (onCopy) await onCopy(); else if (copyText) await navigator.clipboard?.writeText(copyText); } catch { setAnnouncement("Copy failed"); return; } setCopied(true); setAnnouncement("Copied to clipboard"); window.clearTimeout(copyTimer.current); copyTimer.current = window.setTimeout(() => setCopied(false), 1600); }; const regenerate = async () => { if (!onRegenerate || regenerating) return; setSpins((count) => count + 1); const result = onRegenerate(); if (result && typeof result.then === "function") { setRegenerating(true); try { await result; } finally { setRegenerating(false); } } }; const setFeedback = (next: MessageFeedback) => { if (feedback === undefined) setInternalFeedback(next); onFeedbackChange?.(next); }; const openPopover = (next: boolean) => { if (!next && panelRef.current?.contains(document.activeElement)) { downRef.current?.querySelector("button")?.focus({ preventScroll: true }); } setPopoverOpen(next); if (next) { setReasons([]); setComment(""); setThanked(false); } }; const toggleUp = () => { openPopover(false); setFeedback(current === "up" ? null : "up"); }; const toggleDown = () => { if (current === "down") { setFeedback(null); openPopover(false); return; } setFeedback("down"); if (feedbackReasons.length) openPopover(true); }; const submit = () => { onFeedbackSubmit?.({ reasons, comment: comment.trim() }); setThanked(true); setAnnouncement("Thanks for the feedback"); window.clearTimeout(thanksTimer.current); thanksTimer.current = window.setTimeout(() => openPopover(false), 1100); }; // Move focus into the panel once it has mounted and been measured. useEffect(() => { if (!popoverOpen) return; const frame = requestAnimationFrame(() => panelRef.current ?.querySelector("[aria-pressed], textarea") ?.focus({ preventScroll: true }), ); return () => cancelAnimationFrame(frame); }, [popoverOpen]); const pinned = visibility === "always" || popoverOpen || menuOpen; return (
{canCopy ? ( {copied ? ( ) : ( ) : null} {onRegenerate ? ( ) : null}
{thanked ? ( Thanks for the feedback ) : (

What went wrong?

Pick any that apply.

{feedbackReasons.map((reason) => ( {reason.label} ))}