{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reply-toolbar",
  "type": "registry:component",
  "title": "Reply Toolbar",
  "description": "The quiet row under an AI reply where every small action gives something back: copy draws a check, regenerate opens a version switcher, a thumb asks what worked and read aloud turns into an equaliser.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/devl/reply-toolbar.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  CheckIcon,\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  CopyIcon,\n  RefreshCwIcon,\n  ThumbsDownIcon,\n  ThumbsUpIcon,\n  Volume2Icon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type MouseEvent, type ReactElement, type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\ntype Vote = \"up\" | \"down\";\n\nexport interface ReplyToolbarProps {\n  text?: string;\n  initialVersions?: number;\n  maxVersions?: number;\n  reasons?: Record<Vote, string[]>;\n  speakDuration?: number;\n  onRegenerate?: () => void;\n  onVersionChange?: (index: number, total: number) => void;\n  onFeedback?: (vote: Vote | null, reason?: string) => void;\n  onSpeak?: (speaking: boolean) => void;\n  className?: string;\n}\n\nconst defaultReasons: Record<Vote, string[]> = {\n  up: [\"Accurate\", \"Clear\", \"Saved time\"],\n  down: [\"Wrong\", \"Too long\", \"Missed the point\"],\n};\n\nconst spring = { type: \"spring\", stiffness: 500, damping: 34 } as const;\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst press = \"transition-[background-color,color,opacity,scale] duration-150 ease-out active:scale-[0.97]\";\nconst btn = cn(\n  \"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\",\n  press,\n);\nconst smallBtn = cn(\n  \"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\",\n  press,\n);\n\nexport function ReplyToolbar({\n  text = \"\",\n  initialVersions = 1,\n  maxVersions = 5,\n  reasons = defaultReasons,\n  speakDuration = 4200,\n  onRegenerate,\n  onVersionChange,\n  onFeedback,\n  onSpeak,\n  className,\n}: ReplyToolbarProps) {\n  const reduce = useReducedMotion();\n  const [copied, setCopied] = useState(false);\n  const [spinning, setSpinning] = useState(false);\n  const [spins, setSpins] = useState(0);\n  const [total, setTotal] = useState(initialVersions);\n  const [index, setIndex] = useState(initialVersions - 1);\n  const [vote, setVote] = useState<Vote | null>(null);\n  const [asking, setAsking] = useState(false);\n  const [reason, setReason] = useState<string | null>(null);\n  const [speaking, setSpeaking] = useState(false);\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n  const later = (fn: () => void, ms: number) => {\n    timers.current.push(setTimeout(fn, ms));\n  };\n  useEffect(() => () => timers.current.forEach(clearTimeout), []);\n\n  useEffect(() => {\n    if (!copied) return;\n    const t = setTimeout(() => setCopied(false), 1500);\n    return () => clearTimeout(t);\n  }, [copied]);\n\n  useEffect(() => {\n    if (!speaking) return;\n    const t = setTimeout(() => {\n      setSpeaking(false);\n      onSpeak?.(false);\n    }, speakDuration);\n    return () => clearTimeout(t);\n  }, [speaking, speakDuration, onSpeak]);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(text);\n    } catch {}\n    setCopied(true);\n  };\n\n  const regenerate = () => {\n    if (spinning || total >= maxVersions) return;\n    setSpinning(true);\n    setSpins((s) => s + 1);\n    onRegenerate?.();\n    later(\n      () => {\n        const next = total + 1;\n        setSpinning(false);\n        setTotal(next);\n        setIndex(next - 1);\n        onVersionChange?.(next - 1, next);\n      },\n      reduce ? 0 : 900,\n    );\n  };\n\n  const go = (dir: -1 | 1) => {\n    const next = Math.min(total - 1, Math.max(0, index + dir));\n    if (next === index) return;\n    setIndex(next);\n    onVersionChange?.(next, total);\n  };\n\n  const castVote = (v: Vote) => {\n    const next = vote === v ? null : v;\n    setVote(next);\n    setReason(null);\n    setAsking(next !== null);\n    onFeedback?.(next);\n  };\n\n  const pickReason = (r: string) => {\n    setReason(r);\n    onFeedback?.(vote, r);\n    later(() => setAsking(false), 1400);\n  };\n\n  return (\n    <TooltipProvider closeDelay={0} delay={500} timeout={400}>\n    <div className={cn(\"flex flex-col gap-1.5\", className)}>\n      <div className=\"-ms-2 flex items-center gap-0.5\">\n        <Tip label={copied ? \"Copied\" : \"Copy\"}>\n          <button aria-label=\"Copy message\" className={btn} onClick={copy} type=\"button\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                animate={{ scale: 1, opacity: 1, filter: \"blur(0px)\" }}\n                className=\"grid\"\n                exit={{ scale: 0.6, opacity: 0, filter: \"blur(2px)\" }}\n                initial={{ scale: 0.6, opacity: 0, filter: \"blur(2px)\" }}\n                key={copied ? \"check\" : \"copy\"}\n                transition={{ duration: 0.18, ease: easeOut }}\n              >\n                {copied ? (\n                  <svg\n                    aria-hidden\n                    className=\"size-4 text-success-foreground\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2.5}\n                    viewBox=\"0 0 24 24\"\n                  >\n                    <motion.path\n                      animate={{ pathLength: 1 }}\n                      d=\"M5 12.5 10 17.5 19 7\"\n                      initial={{ pathLength: reduce ? 1 : 0 }}\n                      transition={{ duration: 0.3, ease: \"easeOut\" }}\n                    />\n                  </svg>\n                ) : (\n                  <CopyIcon className=\"size-4\" />\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </button>\n        </Tip>\n\n        <Tip label={total >= maxVersions ? \"Version limit reached\" : \"Regenerate\"}>\n          <button\n            aria-busy={spinning}\n            aria-label=\"Regenerate response\"\n            className={cn(btn, \"disabled:pointer-events-none disabled:opacity-50\")}\n            disabled={total >= maxVersions && !spinning}\n            onClick={regenerate}\n            type=\"button\"\n          >\n            <motion.span\n              animate={{ rotate: spins * 360 }}\n              className=\"grid\"\n              transition={reduce ? { duration: 0 } : { duration: 0.8, ease: \"easeInOut\" }}\n            >\n              <RefreshCwIcon className=\"size-4\" />\n            </motion.span>\n          </button>\n        </Tip>\n\n        <AnimatePresence initial={false}>\n          {total > 1 && (\n            <motion.div\n              animate={{ width: \"auto\", opacity: 1 }}\n              className=\"flex items-center overflow-hidden\"\n              exit={{ width: 0, opacity: 0 }}\n              initial={{ width: 0, opacity: 0 }}\n              transition={reduce ? { duration: 0 } : spring}\n            >\n              <div className=\"flex items-center rounded-lg text-muted-foreground text-xs\">\n                <button\n                  aria-label=\"Previous version\"\n                  className={smallBtn}\n                  disabled={index === 0}\n                  onClick={() => go(-1)}\n                  type=\"button\"\n                >\n                  <ChevronLeftIcon className=\"size-3.5\" />\n                </button>\n                <span aria-live=\"polite\" className=\"flex min-w-8 items-center justify-center font-mono tabular-nums\">\n                  <span className=\"relative inline-flex h-4 w-[1ch] justify-center overflow-hidden\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.span\n                        animate={{ y: 0, opacity: 1 }}\n                        exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0 }}\n                        initial={reduce ? { opacity: 0 } : { y: 10, opacity: 0 }}\n                        key={index}\n                        transition={{ duration: 0.2, ease: easeOut }}\n                      >\n                        {index + 1}\n                      </motion.span>\n                    </AnimatePresence>\n                  </span>\n                  /{total}\n                </span>\n                <button\n                  aria-label=\"Next version\"\n                  className={smallBtn}\n                  disabled={index === total - 1}\n                  onClick={() => go(1)}\n                  type=\"button\"\n                >\n                  <ChevronRightIcon className=\"size-3.5\" />\n                </button>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <span aria-hidden className=\"mx-1 h-4 w-px bg-border\" />\n\n        {([\"up\", \"down\"] as const).map((v) => (\n          <Tip key={v} label={v === \"up\" ? \"Good response\" : \"Bad response\"}>\n            <ThumbButton on={vote === v} onClick={() => castVote(v)} vote={v} />\n          </Tip>\n        ))}\n\n        <Tip label={speaking ? \"Stop\" : \"Read aloud\"}>\n          <button\n            aria-label={speaking ? \"Stop reading\" : \"Read aloud\"}\n            aria-pressed={speaking}\n            className={btn}\n            onClick={() => {\n              setSpeaking((s) => !s);\n              onSpeak?.(!speaking);\n            }}\n            type=\"button\"\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {speaking ? (\n                <motion.span\n                  animate={{ opacity: 1, scale: 1 }}\n                  aria-hidden\n                  className=\"flex h-4 items-center gap-[2px]\"\n                  exit={{ opacity: 0, scale: 0.6 }}\n                  initial={{ opacity: 0, scale: 0.6 }}\n                  key=\"eq\"\n                  transition={{ duration: 0.18, ease: easeOut }}\n                >\n                  {[0.55, 0.9, 0.7, 0.45].map((d, i) => (\n                    <motion.span\n                      animate={reduce ? { scaleY: 0.6 } : { scaleY: [0.3, 1, 0.45, 0.85, 0.3] }}\n                      className=\"h-3.5 w-[3px] rounded-full bg-info\"\n                      key={d}\n                      transition={{ duration: d + 0.35, ease: \"easeInOut\", repeat: Number.POSITIVE_INFINITY, delay: i * 0.08 }}\n                    />\n                  ))}\n                </motion.span>\n              ) : (\n                <motion.span\n                  animate={{ opacity: 1, scale: 1 }}\n                  className=\"grid\"\n                  exit={{ opacity: 0, scale: 0.6 }}\n                  initial={{ opacity: 0, scale: 0.6 }}\n                  key=\"vol\"\n                  transition={{ duration: 0.18, ease: easeOut }}\n                >\n                  <Volume2Icon className=\"size-4\" />\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </button>\n        </Tip>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {asking && vote && (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            initial={{ height: 0, opacity: 0 }}\n            transition={reduce ? { duration: 0 } : spring}\n          >\n            <div className=\"flex min-h-10 flex-wrap items-center gap-1.5 rounded-xl border bg-card px-2.5 py-1.5 text-xs\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {reason ? (\n                  <motion.span\n                    animate={{ opacity: 1, y: 0 }}\n                    className=\"flex items-center gap-1.5 text-muted-foreground\"\n                    initial={{ opacity: 0, y: 4 }}\n                    key=\"thanks\"\n                    transition={{ duration: 0.2, ease: easeOut }}\n                  >\n                    <CheckIcon className=\"size-3.5 text-success-foreground\" />\n                    Thanks, noted “{reason}”.\n                  </motion.span>\n                ) : (\n                  <motion.div\n                    className=\"flex flex-1 items-center gap-2\"\n                    exit={{ opacity: 0, y: -4 }}\n                    key=\"ask\"\n                    transition={{ duration: 0.15, ease: \"easeOut\" }}\n                  >\n                    <div className=\"flex flex-1 flex-wrap items-center gap-1.5 py-0.5\">\n                    <span className=\"me-0.5 text-muted-foreground\">{vote === \"up\" ? \"What worked?\" : \"What went wrong?\"}</span>\n                    {reasons[vote].map((r, i) => (\n                      <motion.button\n                        animate={{ opacity: 1, scale: 1 }}\n                        className={cn(\n                          \"relative h-6 rounded-full border bg-background px-2 outline-none after:absolute after:-inset-1 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring\",\n                          press,\n                        )}\n                        initial={reduce ? false : { opacity: 0, scale: 0.95 }}\n                        key={r}\n                        onClick={() => pickReason(r)}\n                        transition={{ duration: 0.2, ease: easeOut, delay: 0.05 + i * 0.03 }}\n                        type=\"button\"\n                      >\n                        {r}\n                      </motion.button>\n                    ))}\n                    </div>\n                    <button\n                      aria-label=\"Dismiss\"\n                      className={cn(\n                        \"relative grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground outline-none after:absolute after:-inset-1 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n                        press,\n                      )}\n                      onClick={() => setAsking(false)}\n                      type=\"button\"\n                    >\n                      <XIcon className=\"size-3.5\" />\n                    </button>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n    </TooltipProvider>\n  );\n}\n\nfunction Tip({ label, children }: { label: ReactNode; children: ReactElement }) {\n  return (\n    <Tooltip>\n      <TooltipTrigger render={children} />\n      <TooltipPopup>{label}</TooltipPopup>\n    </Tooltip>\n  );\n}\n\nfunction ThumbButton({\n  vote,\n  on,\n  onClick,\n  ...props\n}: { vote: Vote; on: boolean; onClick: (event: MouseEvent<HTMLButtonElement>) => void } & Record<string, unknown>) {\n  const reduce = useReducedMotion();\n  const [burst, setBurst] = useState(0);\n  const Icon = vote === \"up\" ? ThumbsUpIcon : ThumbsDownIcon;\n  const tint = vote === \"up\" ? \"bg-success\" : \"bg-destructive\";\n\n  return (\n    <button\n      {...props}\n      aria-label={vote === \"up\" ? \"Good response\" : \"Bad response\"}\n      aria-pressed={on}\n      className={btn}\n      onClick={(event) => {\n        if (!on) setBurst((b) => b + 1);\n        onClick(event);\n      }}\n      type=\"button\"\n    >\n      <motion.span\n        animate={on && !reduce ? { scale: [1, 1.25, 0.96, 1], rotate: vote === \"up\" ? [0, -12, 3, 0] : [0, 12, -3, 0] } : {}}\n        className=\"grid\"\n        key={on ? `on-${burst}` : \"off\"}\n        transition={{ duration: 0.4, ease: \"easeOut\" }}\n      >\n        <Icon className={cn(\"size-4\", on && \"fill-current\")} />\n      </motion.span>\n      {!reduce && burst > 0 && on && (\n        <span aria-hidden className=\"pointer-events-none absolute inset-0\" key={burst}>\n          {Array.from({ length: 6 }, (_, i) => {\n            const angle = (i / 6) * Math.PI * 2 - Math.PI / 2;\n            return (\n              <motion.span\n                animate={{ x: Math.cos(angle) * 15, y: Math.sin(angle) * 15, scale: 0, opacity: 0 }}\n                className={cn(\"absolute top-1/2 left-1/2 -mt-0.5 -ml-0.5 size-1 rounded-full\", tint)}\n                initial={{ x: 0, y: 0, scale: 1.2, opacity: 1 }}\n                key={i}\n                transition={{ duration: 0.5, ease: \"easeOut\" }}\n              />\n            );\n          })}\n        </span>\n      )}\n    </button>\n  );\n}\n"
    }
  ]
}