{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streamed-reply",
  "type": "registry:component",
  "title": "Streamed Reply",
  "description": "An answer that reads its sources first, then softly streams in word by word, with little numbered chips you can hover to peek at each source, all in a card that holds its size the whole time.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/preview-card.json"
  ],
  "files": [
    {
      "path": "registry/devl/streamed-reply.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  ArrowUpRightIcon,\n  CheckIcon,\n  CopyIcon,\n  CornerDownRightIcon,\n  RefreshCwIcon,\n  SparklesIcon,\n  ThumbsDownIcon,\n  ThumbsUpIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n  PreviewCard,\n  PreviewCardPopup,\n  PreviewCardTrigger,\n} from \"@/components/ui/preview-card\";\nimport { cn } from \"@/lib/utils\";\n\n// Stays false until mounted so the server render and the first client render match.\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport interface ReplySource {\n  title: string;\n  domain: string;\n  url?: string;\n  snippet: string;\n}\n\nexport interface StreamedReplyProps {\n  question?: string;\n  answer?: string;\n  sources?: ReplySource[];\n  followUps?: string[];\n  speed?: number;\n  onFollowUp?: (question: string) => void;\n  onFeedback?: (value: \"up\" | \"down\" | null) => void;\n  className?: string;\n}\n\nconst defaultSources: ReplySource[] = [\n  {\n    title: \"Why sourdough rises slower in winter\",\n    domain: \"crumbjournal.com\",\n    snippet:\n      \"Wild yeast activity roughly halves for every 8\\u00a0°C drop, so a cold kitchen can double bulk fermentation time.\",\n  },\n  {\n    title: \"Dough temperature, explained\",\n    domain: \"bakersfield.guide\",\n    snippet:\n      \"Aim for a final dough temperature of 24–26\\u00a0°C. Use warmer water to compensate for a cool room or cold flour.\",\n  },\n  {\n    title: \"Proofing boxes vs. the oven-light trick\",\n    domain: \"hearthandloaf.co\",\n    snippet:\n      \"An oven with only the light on sits around 27–30\\u00a0°C, close to a proofing box for most home bakers.\",\n  },\n];\n\nconst defaultAnswer = `Your starter is probably fine. Wild yeast slows down a lot in the cold, so a chilly kitchen can easily double how long bulk fermentation takes [1].\n\nTry mixing with warmer water so the dough lands around 25\\u00a0°C [2], then let it rise somewhere snug: an oven with just the light on works surprisingly well [3]. Judge by the dough, not the clock: look for a 50–75% rise, a domed top and bubbles along the sides.`;\n\nconst defaultFollowUps = [\n  \"How do I know my starter is active?\",\n  \"Can I proof overnight in the fridge?\",\n];\n\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst tints = [\n  \"bg-orange-500\",\n  \"bg-sky-500\",\n  \"bg-emerald-500\",\n  \"bg-violet-500\",\n  \"bg-rose-500\",\n];\nconst press =\n  \"transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.97]\";\n\ntype Part = { kind: \"text\"; text: string } | { kind: \"cite\"; n: number };\ntype Token = { kind: \"space\" } | { kind: \"word\"; parts: Part[] };\n\nfunction tokenize(answer: string): Token[][] {\n  return answer\n    .trim()\n    .replace(/[ \\t]+(\\[\\d+\\])/g, \"$1\")\n    .split(/\\n\\s*\\n/)\n    .map((para) =>\n      para\n        .split(/([ \\t\\n]+)/)\n        .filter(Boolean)\n        .map((chunk): Token => {\n          if (/^[ \\t\\n]+$/.test(chunk)) return { kind: \"space\" };\n          const parts = chunk\n            .split(/(\\[\\d+\\])/)\n            .filter(Boolean)\n            .map((part): Part => {\n              const cite = /^\\[(\\d+)\\]$/.exec(part);\n              return cite\n                ? { kind: \"cite\", n: Number(cite[1]) }\n                : { kind: \"text\", text: part };\n            });\n          const last = parts.at(-1);\n          const prev = parts.at(-2);\n          if (\n            last?.kind === \"text\" &&\n            prev?.kind === \"cite\" &&\n            /^[.,;:!?]+$/.test(last.text)\n          ) {\n            let i = parts.length - 2;\n            while (parts[i - 1]?.kind === \"cite\") i--;\n            parts.splice(parts.length - 1, 1);\n            const before = parts[i - 1];\n            if (before?.kind === \"text\") before.text += last.text;\n            else parts.splice(i, 0, last);\n          }\n          return { kind: \"word\", parts };\n        }),\n    );\n}\n\nexport function StreamedReply({\n  question = \"My sourdough barely rose overnight. Is my starter dead?\",\n  answer = defaultAnswer,\n  sources = defaultSources,\n  followUps = defaultFollowUps,\n  speed = 38,\n  onFollowUp,\n  onFeedback,\n  className,\n}: StreamedReplyProps) {\n  const reduce = useSafeReducedMotion();\n  const paragraphs = useMemo(() => tokenize(answer), [answer]);\n  const total = useMemo(\n    () => paragraphs.reduce((n, p) => n + p.length, 0),\n    [paragraphs],\n  );\n  const [run, setRun] = useState(0);\n  const [phase, setPhase] = useState<\"searching\" | \"streaming\" | \"done\">(\n    \"searching\",\n  );\n  const [shown, setShown] = useState(0);\n\n  useEffect(() => {\n    if (reduce) {\n      setShown(total);\n      setPhase(\"done\");\n      return;\n    }\n    setShown(0);\n    setPhase(\"searching\");\n    let timer: ReturnType<typeof setTimeout>;\n    let count = 0;\n    const tick = () => {\n      count = Math.min(total, count + 1 + Math.round(Math.random() * 2));\n      setShown(count);\n      if (count >= total) {\n        setPhase(\"done\");\n        return;\n      }\n      timer = setTimeout(tick, speed + Math.random() * speed);\n    };\n    timer = setTimeout(() => {\n      setPhase(\"streaming\");\n      tick();\n    }, 1100);\n    return () => clearTimeout(timer);\n  }, [run, total, speed, reduce]);\n\n  const plain = answer.replace(/\\s?\\[\\d+\\]/g, \"\");\n  const done = phase === \"done\";\n  let index = 0;\n\n  return (\n    <article\n      className={cn(\n        \"w-full max-w-lg rounded-2xl border bg-card p-4 text-sm shadow-xs/5\",\n        className,\n      )}\n    >\n      <div className=\"mb-3 flex items-start gap-2.5\">\n        <span className=\"mt-0.5 grid size-6 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground\">\n          <SparklesIcon className=\"size-3.5\" />\n        </span>\n        <p className=\"text-pretty font-medium text-foreground leading-6\">\n          {question}\n        </p>\n      </div>\n\n      <div\n        aria-label=\"Sources\"\n        className=\"-mx-1 mb-3 flex gap-2 overflow-x-auto px-1 pb-1\"\n        role=\"list\"\n      >\n        {sources.map((src, i) => (\n          <motion.a\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            className={cn(\n              \"flex min-w-36 flex-1 basis-0 flex-col gap-1.5 rounded-xl border bg-background p-2.5 outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring\",\n              press,\n            )}\n            href={src.url ?? \"#\"}\n            initial={reduce ? false : { opacity: 0, y: 6, scale: 0.97 }}\n            key={`${run}-${src.domain}`}\n            onClick={(e) => !src.url && e.preventDefault()}\n            role=\"listitem\"\n            transition={{ duration: 0.3, ease: easeOut, delay: 0.1 + i * 0.04 }}\n          >\n            <span className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n              <Favicon domain={src.domain} i={i} />\n              <span className=\"truncate\">{src.domain}</span>\n              <span className=\"ms-auto font-mono tabular-nums\">{i + 1}</span>\n            </span>\n            <span className=\"line-clamp-2 text-foreground text-xs leading-4\">\n              {src.title}\n            </span>\n          </motion.a>\n        ))}\n      </div>\n\n      {/* An invisible copy of the full answer sizes the card from the first frame. */}\n      <div className=\"grid leading-6 *:col-start-1 *:row-start-1\">\n        <div aria-hidden className=\"invisible\">\n          {paragraphs.map((tokens, pi) => (\n            <p className=\"mb-2.5 last:mb-0\" key={pi}>\n              {tokens.map((tok, ti) =>\n                tok.kind === \"space\" ? (\n                  <span key={ti}> </span>\n                ) : (\n                  <span className=\"whitespace-nowrap\" key={ti}>\n                    {tok.parts.map((part, k) =>\n                      part.kind === \"cite\" ? (\n                        sources[part.n - 1] ? (\n                          <span className={chip} key={k}>\n                            {part.n}\n                          </span>\n                        ) : null\n                      ) : (\n                        <span key={k}>{part.text}</span>\n                      ),\n                    )}\n                  </span>\n                ),\n              )}\n            </p>\n          ))}\n        </div>\n        <div aria-busy={!done} aria-live=\"polite\">\n          {phase === \"searching\" ? (\n            <div className=\"flex flex-col\">\n              <Shimmer>{`Reading ${sources.length} sources…`}</Shimmer>\n              <div aria-hidden className=\"mt-2 flex flex-col gap-2.5\">\n                {[\"w-full\", \"w-11/12\", \"w-3/5\"].map((w) => (\n                  <span className={cn(\"h-2.5 rounded-full bg-muted/70\", w)} key={w} />\n                ))}\n              </div>\n            </div>\n          ) : (\n            paragraphs.map((tokens, pi) => (\n              <p\n                className=\"mb-2.5 text-foreground/90 last:mb-0\"\n                key={`${run}-${pi}`}\n              >\n                {tokens.map((tok, ti) => {\n                  const at = index++;\n                  if (at >= shown) return null;\n                  const key = `${pi}-${ti}`;\n                  if (tok.kind === \"space\") return <span key={key}> </span>;\n                  return (\n                    <span className=\"whitespace-nowrap\" key={key}>\n                      {tok.parts.map((part, k) => {\n                        if (part.kind === \"cite\") {\n                          const src = sources[part.n - 1];\n                          return src ? (\n                            <Citation key={k} n={part.n} source={src} />\n                          ) : null;\n                        }\n                        return (\n                          <motion.span\n                            animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                            initial={{ opacity: 0, filter: \"blur(4px)\" }}\n                            key={k}\n                            transition={{ duration: 0.3, ease: \"easeOut\" }}\n                          >\n                            {part.text}\n                          </motion.span>\n                        );\n                      })}\n                    </span>\n                  );\n                })}\n                {phase === \"streaming\" && caretInParagraph(pi, paragraphs, shown) && (\n                  <Caret />\n                )}\n              </p>\n            ))\n          )}\n        </div>\n      </div>\n\n      <motion.div\n        animate={done ? { opacity: 1, y: 0 } : { opacity: 0, y: 4 }}\n        className={cn(!done && \"pointer-events-none\")}\n        inert={!done}\n        initial={false}\n        transition={{ duration: done ? 0.25 : 0.12, ease: easeOut }}\n      >\n        <Actions\n          onFeedback={onFeedback}\n          onRegenerate={() => setRun((r) => r + 1)}\n          run={run}\n          text={plain}\n        />\n        {followUps.length > 0 && (\n          <div className=\"mt-3 border-t pt-2\">\n            <div className=\"-mx-2 flex flex-col gap-0.5\">\n              {followUps.map((q, i) => (\n                <motion.button\n                  animate={done ? { opacity: 1, x: 0 } : { opacity: 0, x: -6 }}\n                  className={cn(\n                    \"group/fu flex min-h-8 items-center gap-2 rounded-lg px-2 py-1.5 text-start text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n                    press,\n                  )}\n                  initial={false}\n                  key={q}\n                  onClick={() => onFollowUp?.(q)}\n                  transition={{\n                    duration: 0.25,\n                    ease: easeOut,\n                    delay: done ? 0.06 + i * 0.04 : 0,\n                  }}\n                  type=\"button\"\n                >\n                  <CornerDownRightIcon className=\"size-3.5 shrink-0\" />\n                  <span className=\"flex-1\">{q}</span>\n                  <ArrowUpRightIcon className=\"size-3.5 shrink-0 opacity-0 transition-[opacity,translate] duration-150 ease-out group-hover/fu:translate-x-0.5 group-hover/fu:opacity-100 group-focus-visible/fu:opacity-100\" />\n                </motion.button>\n              ))}\n            </div>\n          </div>\n        )}\n      </motion.div>\n    </article>\n  );\n}\n\nfunction caretInParagraph(pi: number, paragraphs: Token[][], shown: number) {\n  let start = 0;\n  for (let i = 0; i < paragraphs.length; i++) {\n    const end = start + paragraphs[i].length;\n    if (shown <= end || i === paragraphs.length - 1) return i === pi;\n    start = end;\n  }\n  return false;\n}\n\nconst chip =\n  \"ms-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 align-[2px] font-medium font-mono text-[10px] tabular-nums\";\n\nfunction Favicon({ domain, i }: { domain: string; i: number }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid size-4 shrink-0 place-items-center rounded-full font-semibold text-[9px] text-white uppercase\",\n        tints[i % tints.length],\n      )}\n    >\n      {domain[0]}\n    </span>\n  );\n}\n\nfunction Citation({ n, source }: { n: number; source: ReplySource }) {\n  const reduce = useSafeReducedMotion();\n  return (\n    <PreviewCard>\n      <PreviewCardTrigger\n        aria-label={`Source ${n}: ${source.title}`}\n        className={cn(\n          chip,\n          \"relative bg-muted text-muted-foreground no-underline outline-none transition-colors duration-150 after:absolute after:-inset-x-1.5 after:-inset-y-2 hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-primary data-popup-open:text-primary-foreground\",\n        )}\n        closeDelay={120}\n        delay={150}\n        href={source.url ?? \"#\"}\n        onClick={(e) => !source.url && e.preventDefault()}\n        render={\n          <motion.a\n            animate={{ opacity: 1, scale: 1 }}\n            initial={reduce ? false : { opacity: 0, scale: 0.8 }}\n            transition={{ duration: 0.2, ease: easeOut }}\n          />\n        }\n      >\n        {n}\n      </PreviewCardTrigger>\n      <PreviewCardPopup\n        className=\"w-72 flex-col gap-2 text-pretty p-3 duration-150 ease-out\"\n        sideOffset={6}\n      >\n        <span className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n          <Favicon domain={source.domain} i={n - 1} />\n          {source.domain}\n          <span className=\"ms-auto font-mono tabular-nums\">[{n}]</span>\n        </span>\n        <span className=\"font-medium text-foreground text-sm leading-5\">\n          {source.title}\n        </span>\n        <span className=\"text-muted-foreground text-xs leading-5\">\n          {source.snippet}\n        </span>\n      </PreviewCardPopup>\n    </PreviewCard>\n  );\n}\n\nfunction Caret() {\n  return (\n    <motion.span\n      animate={{ opacity: [1, 0.25, 1] }}\n      aria-hidden\n      className=\"ms-1 inline-block size-2 rounded-full bg-foreground align-[1px]\"\n      transition={{\n        duration: 0.9,\n        ease: \"easeInOut\",\n        repeat: Number.POSITIVE_INFINITY,\n      }}\n    />\n  );\n}\n\nfunction Shimmer({ children }: { children: string }) {\n  return (\n    <motion.span\n      animate={{ backgroundPosition: [\"100% 0%\", \"-100% 0%\"] }}\n      className=\"bg-clip-text font-medium text-transparent\"\n      style={{\n        backgroundImage:\n          \"linear-gradient(90deg, var(--color-muted-foreground) 35%, var(--color-foreground) 50%, var(--color-muted-foreground) 65%)\",\n        backgroundSize: \"200% 100%\",\n      }}\n      transition={{\n        duration: 1.5,\n        ease: \"linear\",\n        repeat: Number.POSITIVE_INFINITY,\n      }}\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction Actions({\n  text,\n  run,\n  onRegenerate,\n  onFeedback,\n}: {\n  text: string;\n  run: number;\n  onRegenerate: () => void;\n  onFeedback?: (value: \"up\" | \"down\" | null) => void;\n}) {\n  const reduce = useSafeReducedMotion();\n  const [copied, setCopied] = useState(false);\n  const [voted, setVoted] = useState<{ run: number; v: \"up\" | \"down\" }>();\n  const vote = voted?.run === run ? voted.v : null;\n  const [spins, setSpins] = useState(0);\n\n  useEffect(() => {\n    if (!copied) return;\n    const t = setTimeout(() => setCopied(false), 1600);\n    return () => clearTimeout(t);\n  }, [copied]);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(text);\n    } catch {}\n    setCopied(true);\n  };\n\n  const castVote = (v: \"up\" | \"down\") => {\n    const next = vote === v ? null : v;\n    setVoted(next ? { run, v: next } : undefined);\n    onFeedback?.(next);\n  };\n\n  const 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\",\n    press,\n  );\n\n  return (\n    <div className=\"-ms-2 mt-3 flex h-8 items-center gap-0.5\">\n      <button\n        aria-label={copied ? \"Copied\" : \"Copy answer\"}\n        className={btn}\n        onClick={copy}\n        type=\"button\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ scale: 1, opacity: 1, filter: \"blur(0px)\" }}\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              <CheckIcon className=\"size-4 text-success-foreground\" />\n            ) : (\n              <CopyIcon className=\"size-4\" />\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </button>\n      <button\n        aria-label=\"Regenerate\"\n        className={btn}\n        onClick={() => {\n          setSpins((n) => n + 1);\n          onRegenerate();\n        }}\n        type=\"button\"\n      >\n        <motion.span\n          animate={{ rotate: reduce ? 0 : spins * 360 }}\n          className=\"grid\"\n          initial={false}\n          transition={{ duration: 0.5, ease: easeOut }}\n        >\n          <RefreshCwIcon className=\"size-4\" />\n        </motion.span>\n      </button>\n      <span aria-hidden className=\"mx-1 h-4 w-px bg-border\" />\n      {([\"up\", \"down\"] as const).map((v) => {\n        const Icon = v === \"up\" ? ThumbsUpIcon : ThumbsDownIcon;\n        const on = vote === v;\n        return (\n          <button\n            aria-label={v === \"up\" ? \"Good answer\" : \"Bad answer\"}\n            aria-pressed={on}\n            className={cn(btn, on && \"text-foreground\")}\n            key={v}\n            onClick={() => castVote(v)}\n            type=\"button\"\n          >\n            <motion.span\n              animate={\n                on && !reduce\n                  ? { scale: [1, 1.2, 1], rotate: v === \"up\" ? [0, -10, 0] : [0, 10, 0] }\n                  : { scale: 1, rotate: 0 }\n              }\n              className=\"grid\"\n              transition={{ duration: 0.3, ease: \"easeOut\" }}\n            >\n              <Icon\n                className={cn(\n                  \"size-4 transition-[fill] duration-150\",\n                  on && \"fill-current\",\n                )}\n              />\n            </motion.span>\n          </button>\n        );\n      })}\n      <AnimatePresence>\n        {vote && (\n          <motion.span\n            animate={{ opacity: 1, x: 0 }}\n            className=\"ms-1 text-muted-foreground text-xs\"\n            exit={{ opacity: 0, x: -4, transition: { duration: 0.12 } }}\n            initial={{ opacity: 0, x: -4 }}\n            transition={{ duration: 0.2, ease: easeOut }}\n          >\n            Thanks for the feedback\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
    }
  ]
}