"use client"; import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, RefreshCwIcon, ThumbsDownIcon, ThumbsUpIcon, Volume2Icon, XIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type MouseEvent, type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; type Vote = "up" | "down"; export interface ReplyToolbarProps { text?: string; initialVersions?: number; maxVersions?: number; reasons?: Record; speakDuration?: number; onRegenerate?: () => void; onVersionChange?: (index: number, total: number) => void; onFeedback?: (vote: Vote | null, reason?: string) => void; onSpeak?: (speaking: boolean) => void; className?: string; } const defaultReasons: Record = { up: ["Accurate", "Clear", "Saved time"], down: ["Wrong", "Too long", "Missed the point"], }; const spring = { type: "spring", stiffness: 500, damping: 34 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[background-color,color,opacity,scale] duration-150 ease-out active:scale-[0.97]"; const btn = cn( "relative grid size-8 place-items-center rounded-lg text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-pressed:text-foreground", press, ); const smallBtn = cn( "relative grid size-7 place-items-center rounded-md outline-none after:absolute after:-inset-0.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40", press, ); export function ReplyToolbar({ text = "", initialVersions = 1, maxVersions = 5, reasons = defaultReasons, speakDuration = 4200, onRegenerate, onVersionChange, onFeedback, onSpeak, className, }: ReplyToolbarProps) { const reduce = useReducedMotion(); const [copied, setCopied] = useState(false); const [spinning, setSpinning] = useState(false); const [spins, setSpins] = useState(0); const [total, setTotal] = useState(initialVersions); const [index, setIndex] = useState(initialVersions - 1); const [vote, setVote] = useState(null); const [asking, setAsking] = useState(false); const [reason, setReason] = useState(null); const [speaking, setSpeaking] = useState(false); const timers = useRef[]>([]); const later = (fn: () => void, ms: number) => { timers.current.push(setTimeout(fn, ms)); }; useEffect(() => () => timers.current.forEach(clearTimeout), []); useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), 1500); return () => clearTimeout(t); }, [copied]); useEffect(() => { if (!speaking) return; const t = setTimeout(() => { setSpeaking(false); onSpeak?.(false); }, speakDuration); return () => clearTimeout(t); }, [speaking, speakDuration, onSpeak]); const copy = async () => { try { await navigator.clipboard.writeText(text); } catch {} setCopied(true); }; const regenerate = () => { if (spinning || total >= maxVersions) return; setSpinning(true); setSpins((s) => s + 1); onRegenerate?.(); later( () => { const next = total + 1; setSpinning(false); setTotal(next); setIndex(next - 1); onVersionChange?.(next - 1, next); }, reduce ? 0 : 900, ); }; const go = (dir: -1 | 1) => { const next = Math.min(total - 1, Math.max(0, index + dir)); if (next === index) return; setIndex(next); onVersionChange?.(next, total); }; const castVote = (v: Vote) => { const next = vote === v ? null : v; setVote(next); setReason(null); setAsking(next !== null); onFeedback?.(next); }; const pickReason = (r: string) => { setReason(r); onFeedback?.(vote, r); later(() => setAsking(false), 1400); }; return (
= maxVersions ? "Version limit reached" : "Regenerate"}> {total > 1 && (
{index + 1} /{total}
)}
{(["up", "down"] as const).map((v) => ( castVote(v)} vote={v} /> ))}
{asking && vote && (
{reason ? ( Thanks, noted “{reason}”. ) : (
{vote === "up" ? "What worked?" : "What went wrong?"} {reasons[vote].map((r, i) => ( pickReason(r)} transition={{ duration: 0.2, ease: easeOut, delay: 0.05 + i * 0.03 }} type="button" > {r} ))}
)}
)}
); } function Tip({ label, children }: { label: ReactNode; children: ReactElement }) { return ( {label} ); } function ThumbButton({ vote, on, onClick, ...props }: { vote: Vote; on: boolean; onClick: (event: MouseEvent) => void } & Record) { const reduce = useReducedMotion(); const [burst, setBurst] = useState(0); const Icon = vote === "up" ? ThumbsUpIcon : ThumbsDownIcon; const tint = vote === "up" ? "bg-success" : "bg-destructive"; return ( ); }