{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Streaming assistant text where words animate in as they arrive, with a caret that rides the last glyph and inline citation pills.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, useEffect, useRef } from \"react\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/**\n * Hoisted and frozen. Motion restarts an animation whenever the transition it is\n * given changes, and this component re-renders on every token — so the object\n * has to be the same reference every time.\n */\nconst WORD_TRANSITION = { duration: 0.22, ease: EASE_OUT } as const;\nconst WORD_BLUR_PX = 4;\n/** `[1]` style markers become citation pills. */\nconst CITATION_MARKER = /^\\[(\\d+)\\]$/;\n/**\n * Split on whitespace *and* on markers, so a marker is its own token even when\n * punctuation is glued to it — `compute [1],` has to yield `[1]` and `,`\n * separately or the pill never matches.\n */\nconst TOKEN_SPLIT = /(\\s+|\\[\\d+\\])/;\n/** Anything without a letter or digit is punctuation and is not animated. */\nconst HAS_WORD_CHARACTER = /[\\p{L}\\p{N}]/u;\nconst WHITESPACE_ONLY = /^\\s+$/;\n\nexport type AIResponseCitation = {\n  id: string;\n  /** The number shown in the pill, matching the `[n]` marker in the text. */\n  index: number;\n  title: string;\n  /**\n   * Where the source lives, when it lives anywhere.\n   *\n   * Optional on purpose: most retrieval is over internal documents that have no\n   * public URL, and a required field there just pushes people into inventing\n   * `example.com` links that go nowhere. Without a url the pill renders as plain\n   * text instead of a dead link.\n   */\n  url?: string;\n};\n\nexport type AIResponseProps = {\n  /** Sources referenced by `[n]` markers in the text. */\n  citations?: AIResponseCitation[];\n  className?: string;\n  /** Shows a caret after the last word. */\n  isStreaming?: boolean;\n  /** The response so far. Re-render it as it grows. */\n  text: string;\n};\n\ntype Token = {\n  citation?: AIResponseCitation;\n  value: string;\n};\n\nconst tokenize = (text: string, citations: AIResponseCitation[]): Token[] =>\n  text\n    .split(TOKEN_SPLIT)\n    .filter((value) => value !== \"\")\n    .map((value) => {\n      const match = value.match(CITATION_MARKER);\n      if (!match) {\n        return { value };\n      }\n      const index = Number(match[1]);\n      const citation = citations.find((entry) => entry.index === index);\n      return citation ? { citation, value } : { value };\n    });\n\n/**\n * Streaming assistant text.\n *\n * Words animate in as they *arrive*, not on a fixed timer — the component\n * remembers how many tokens it had last render and only animates the new ones.\n * A timer-driven typewriter drifts out of step with the real stream and starts\n * lying about how fast the model is answering.\n */\nconst AIResponse = ({\n  citations = [],\n  className,\n  isStreaming = false,\n  text,\n}: AIResponseProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const tokens = tokenize(text, citations);\n\n  // How many tokens had already been painted before this render. Everything at\n  // or after this index is new and gets the entrance.\n  const paintedRef = useRef(0);\n  const firstNewIndex = paintedRef.current;\n\n  useEffect(() => {\n    paintedRef.current = tokens.length;\n  }, [tokens.length]);\n\n  return (\n    <p\n      className={cn(\n        \"text-pretty text-foreground text-sm leading-relaxed\",\n        className\n      )}\n    >\n      {tokens.map((token, index) => {\n        const isNew = index >= firstNewIndex;\n        // Keyed by position only. Keying by text too would remount the last word\n        // every time a token extends it, so it would re-animate on every frame of\n        // the stream.\n        const key = index;\n\n        // Whitespace and bare punctuation stay as text. Wrapping a comma in its\n        // own inline-block would let the line break between a word and its\n        // punctuation.\n        if (\n          WHITESPACE_ONLY.test(token.value) ||\n          !(token.citation || HAS_WORD_CHARACTER.test(token.value))\n        ) {\n          return <Fragment key={key}>{token.value}</Fragment>;\n        }\n\n        if (token.citation) {\n          return (\n            <AIResponseCitationPill\n              citation={token.citation}\n              isNew={isNew}\n              key={key}\n              shouldReduceMotion={Boolean(shouldReduceMotion)}\n            />\n          );\n        }\n\n        return (\n          <motion.span\n            animate={{ filter: \"blur(0px)\", opacity: 1, y: 0 }}\n            className=\"inline-block\"\n            initial={\n              isNew && !shouldReduceMotion\n                ? { filter: `blur(${WORD_BLUR_PX}px)`, opacity: 0, y: 2 }\n                : false\n            }\n            key={key}\n            // No stagger delay, deliberately. The transition object has to stay\n            // identical across renders: a delay derived from the render-time\n            // index goes negative as the text grows, and the entrance then never\n            // resolves — words stay blurred forever. Token arrival is the stagger.\n            transition={shouldReduceMotion ? { duration: 0 } : WORD_TRANSITION}\n          >\n            {token.value}\n          </motion.span>\n        );\n      })}\n      {isStreaming ? (\n        <AIResponseCaret shouldReduceMotion={shouldReduceMotion} />\n      ) : null}\n    </p>\n  );\n};\n\nconst AIResponseCaret = ({\n  shouldReduceMotion,\n}: {\n  shouldReduceMotion: boolean | null;\n}) => (\n  // Inline rather than absolutely positioned, so it rides the last glyph for\n  // free and never has to be told where the text ended.\n  <motion.span\n    animate={shouldReduceMotion ? { opacity: 1 } : { opacity: [1, 0.15, 1] }}\n    aria-hidden=\"true\"\n    className=\"ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] rounded-full bg-current align-baseline\"\n    transition={\n      shouldReduceMotion\n        ? { duration: 0 }\n        : { duration: 1, ease: \"linear\", repeat: Number.POSITIVE_INFINITY }\n    }\n  />\n);\n\nconst AIResponseCitationPill = ({\n  citation,\n  isNew,\n  shouldReduceMotion,\n}: {\n  citation: AIResponseCitation;\n  isNew: boolean;\n  shouldReduceMotion: boolean;\n}) => {\n  const shared = {\n    animate: { opacity: 1, scale: 1 },\n    // No left margin: the marker is already preceded by a space in the text, so\n    // adding one here doubles the gap.\n    className:\n      \"mr-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-border bg-muted px-1 align-super font-medium text-[10px] text-muted-foreground no-underline\",\n    initial:\n      isNew && !shouldReduceMotion\n        ? { opacity: 0, scale: 0.6 }\n        : (false as const),\n    title: citation.title,\n    transition: shouldReduceMotion\n      ? { duration: 0 }\n      : { bounce: 0.1, duration: 0.25, type: \"spring\" as const },\n  };\n\n  // An internal document has nowhere to go, so it is not dressed up as a link —\n  // no hover affordance, no pointer, nothing to click and be disappointed by.\n  if (!citation.url) {\n    return <motion.span {...shared}>{citation.index}</motion.span>;\n  }\n\n  return (\n    <motion.a\n      {...shared}\n      className={`${shared.className} transition-colors hover:border-foreground/30 hover:text-foreground`}\n      href={citation.url}\n      rel=\"noopener noreferrer\"\n      target=\"_blank\"\n    >\n      {citation.index}\n    </motion.a>\n  );\n};\n\nexport default AIResponse;\n","path":"index.tsx","target":"components/smoothui/ai-response/index.tsx","type":"registry:ui"}],"name":"ai-response","registryDependencies":[],"title":"Ai Response","type":"registry:ui"}