{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-answer",
  "title": "AI Answer",
  "description": "A search-answer section — the question, the sources it stands on, prose whose every sentence carries a numbered chip back to a source, follow-ups, and a streaming pass where sources land before the text.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "tooltip",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/ai-answer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AlertCircle, ArrowUpRight, BookOpen, Globe, RefreshCcw, SearchX, Sparkles } from \"lucide-react\"\n\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  AiAnswerFollowUp,\n  AiAnswerSegment,\n  AiAnswerSource,\n  AiAnswerStatus,\n} from \"./ai-answer.contract\"\n\n/**\n * `www.` is noise in a source card, and a scheme is noise too (in case the data\n * layer handed over a URL where a host was asked for). Everything else is left\n * alone — a subdomain is frequently the whole point: `docs.stripe.com` is not\n * `stripe.com`, `arxiv.org` is not `blog.arxiv.org`.\n */\nfunction displayDomain(domain: string): string {\n  return domain\n    .trim()\n    .replace(/^[a-z][a-z0-9+.-]*:\\/\\//i, \"\")\n    .replace(/^www\\./i, \"\")\n    .replace(/\\/+$/, \"\")\n}\n\n/** First alphanumeric of the host, for the favicon fallback tile. */\nfunction monogram(domain: string): string {\n  const match = displayDomain(domain).match(/[a-z0-9]/i)\n  return match ? match[0].toUpperCase() : \"\"\n}\n\n/**\n * Read once, then kept in sync. Decorative motion is turned off with\n * `motion-reduce:` utilities; this hook exists for the one behaviour a class\n * cannot reach — `scrollIntoView({ behavior })` takes a JS argument, not a class.\n */\nfunction usePrefersReducedMotion(): boolean {\n  const [reduced, setReduced] = React.useState(false)\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const update = () => setReduced(query.matches)\n    update()\n    query.addEventListener(\"change\", update)\n    return () => query.removeEventListener(\"change\", update)\n  }, [])\n\n  return reduced\n}\n\n/* ------------------------------------------------------------------ grouping */\n\ntype AnswerBlock =\n  | { kind: \"paragraph\"; key: string; segments: AiAnswerSegment[] }\n  | { kind: \"list\"; key: string; segments: AiAnswerSegment[] }\n  | { kind: \"heading\"; key: string; segment: AiAnswerSegment }\n\n/**\n * Segments arrive sentence by sentence, because a sentence is the unit a citation\n * attaches to. Rendering one block per sentence would produce a stack of one-line\n * paragraphs that reads like a receipt, so consecutive `text` segments are folded\n * into one flowing paragraph and consecutive `bullet` segments into one list.\n * Pure function of the array — no state, safe to run on every render.\n */\nfunction groupSegments(segments: readonly AiAnswerSegment[]): AnswerBlock[] {\n  const blocks: AnswerBlock[] = []\n\n  for (const segment of segments) {\n    const kind = segment.kind ?? \"text\"\n\n    if (kind === \"heading\") {\n      blocks.push({ kind: \"heading\", key: segment.id, segment })\n      continue\n    }\n\n    const target: \"paragraph\" | \"list\" = kind === \"bullet\" ? \"list\" : \"paragraph\"\n    const last = blocks[blocks.length - 1] as AnswerBlock | undefined\n\n    if (\n      last &&\n      (last.kind === \"paragraph\" || last.kind === \"list\") &&\n      last.kind === target &&\n      !segment.startsParagraph\n    ) {\n      last.segments.push(segment)\n      continue\n    }\n\n    blocks.push(\n      target === \"list\"\n        ? { kind: \"list\", key: segment.id, segments: [segment] }\n        : { kind: \"paragraph\", key: segment.id, segments: [segment] },\n    )\n  }\n\n  return blocks\n}\n\n/** Authored order preserved, repeats collapsed: \"…[2][2]\" is a rendering bug, not data. */\nfunction uniqueRefs(refs: readonly number[]): number[] {\n  return Array.from(new Set(refs))\n}\n\n/* -------------------------------------------------------------------- pieces */\n\n/** Shared geometry for the `[n]` pill, so a chip and a source card badge line up. */\nconst NUMBER_PILL =\n  \"inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full border px-1 font-mono text-[10px] leading-none tabular-nums\"\n\n/**\n * A favicon that is allowed to fail. `failedSrc` stores the URL that broke rather\n * than a boolean, so a card recycled onto a different source retries the new\n * image instead of inheriting the previous one's failure — and no effect is\n * needed to reset it.\n */\nfunction SourceFavicon({ source }: { source: AiAnswerSource }) {\n  const src = source.faviconUrl\n  const [failedSrc, setFailedSrc] = React.useState<string | null>(null)\n  const broken = src !== undefined && failedSrc === src\n  const letter = monogram(source.domain)\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-5 shrink-0 items-center justify-center overflow-hidden rounded border bg-muted text-[9px] font-semibold text-muted-foreground\"\n    >\n      {src !== undefined && !broken ? (\n        // eslint-disable-next-line @next/next/no-img-element -- consumer-supplied remote URL, not a local optimizable asset\n        <img\n          alt=\"\"\n          className=\"size-full object-cover\"\n          decoding=\"async\"\n          loading=\"lazy\"\n          onError={() => setFailedSrc(src)}\n          src={src}\n        />\n      ) : letter ? (\n        letter\n      ) : (\n        <Globe className=\"size-3\" />\n      )}\n    </span>\n  )\n}\n\n/** The hover/focus reveal. Lives on `bg-foreground`, so secondary text drops to opacity. */\nfunction SourcePreview({ source }: { source: AiAnswerSource }) {\n  return (\n    <span className=\"flex min-w-0 flex-col gap-1 text-left\">\n      <span className=\"flex min-w-0 items-center gap-1.5 text-[11px]\">\n        <span className=\"font-mono tabular-nums opacity-70\">[{source.n}]</span>\n        <span className=\"min-w-0 wrap-anywhere font-medium\">{displayDomain(source.domain)}</span>\n      </span>\n      <span className=\"min-w-0 wrap-anywhere text-xs font-medium leading-snug\">{source.title}</span>\n      {source.snippet && (\n        <span className=\"min-w-0 text-xs leading-snug opacity-70\">{source.snippet}</span>\n      )}\n      <span className=\"min-w-0 wrap-anywhere font-mono text-[10px] opacity-70\">{source.url}</span>\n    </span>\n  )\n}\n\n/** Ghost lines that stand in for prose — same rhythm as `leading-7` text. */\nfunction SkeletonLines({ count, widths }: { count: number; widths: readonly string[] }) {\n  return (\n    <div aria-hidden=\"true\" className=\"flex flex-col gap-2.5 py-1\">\n      {Array.from({ length: count }, (_, index) => (\n        <div\n          className={cn(\"h-3 animate-pulse rounded bg-muted motion-reduce:animate-none\", widths[index % widths.length])}\n          key={index}\n        />\n      ))}\n    </div>\n  )\n}\n\nconst PROSE_WIDTHS = [\"w-full\", \"w-[92%]\", \"w-full\", \"w-3/5\"] as const\n\n/**\n * A `heading` segment opens a section INSIDE the answer, so it must sit exactly\n * one level under the query heading — otherwise a screen reader's outline reads\n * the answer's subsections as siblings of the question that produced them.\n */\nconst SUB_HEADING = { h1: \"h2\", h2: \"h3\", h3: \"h4\", h4: \"h5\" } as const\n\n/** The token cursor. Decorative — reduced motion keeps the bar, drops the blink. */\nfunction Caret() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 animate-pulse rounded-full bg-primary align-middle motion-reduce:animate-none\"\n      data-caret=\"\"\n    />\n  )\n}\n\n/* ---------------------------------------------------------------- component */\n\nexport interface AiAnswerProps extends React.HTMLAttributes<HTMLElement> {\n  /** Envelope state — whether the answer arrived at all. */\n  status: AiAnswerStatus\n  /** The question. Echoed as the heading in every state, including `loading`. */\n  query: string\n  /** Answer prose, sentence by sentence. Consecutive `text` segments flow into one paragraph. */\n  segments: readonly AiAnswerSegment[]\n  /** Retrieved sources. Numbering comes from `source.n`, never from this order. */\n  sources: readonly AiAnswerSource[]\n  /** Suggested next questions. Rendered only when `onFollowUp` is wired — no dead chips. */\n  followUps: readonly AiAnswerFollowUp[]\n  /**\n   * Tokens are still arriving: sources render in full, the prose renders as far\n   * as it got, a caret trails the last sentence and two ghost lines hold the\n   * space below. Follow-ups stay hidden until the answer is finished.\n   */\n  streaming?: boolean\n  /** Wire it and the follow-up list appears; omit it and the block is dropped rather than faked. */\n  onFollowUp?: (followUp: AiAnswerFollowUp) => void\n  /** Fires when a citation chip is clicked, after the matching source card is pinned and scrolled to. */\n  onCitationSelect?: (source: AiAnswerSource) => void\n  /**\n   * Intercept a plain left click on a source card — open it in a reader panel\n   * instead of a new tab. Modified clicks (⌘/Ctrl/Shift/Alt, middle click) are\n   * left to the browser, so \"open in a new tab\" never stops working.\n   */\n  onSourceSelect?: (source: AiAnswerSource, event: React.MouseEvent<HTMLElement>) => void\n  /** Renders \"Try again\" in the `error` branch; omit it to hide the affordance. */\n  onRetry?: () => void\n  /** Anchor target for source cards. Default `_blank` (with `rel=\"noreferrer\"`). */\n  target?: React.HTMLAttributeAnchorTarget\n  /** Heading tag for the query echo — match the surrounding document outline. Default `h2`. */\n  headingLevel?: \"h1\" | \"h2\" | \"h3\" | \"h4\"\n  /** Reveal the source card on chip hover/focus. Default true. */\n  preview?: boolean\n  /** Tooltip open delay in ms. Default 250. */\n  delayDuration?: number\n  /** Ghost prose lines drawn in the `loading` branch. Clamped to >= 1. Default 4. */\n  loadingLines?: number\n  /** Replaces the default `empty` body. */\n  emptyState?: React.ReactNode\n  /** Message shown in the `error` branch. */\n  errorMessage?: string\n}\n\n/**\n * A search-answer section: the question, the sources it stands on, prose whose\n * every sentence carries a numbered chip back to a source, and the follow-ups\n * that keep the session going — in four data states plus streaming.\n */\nexport const AiAnswer = React.forwardRef<HTMLElement, AiAnswerProps>(\n  (\n    {\n      status,\n      query,\n      segments,\n      sources,\n      followUps,\n      streaming = false,\n      onFollowUp,\n      onCitationSelect,\n      onSourceSelect,\n      onRetry,\n      target = \"_blank\",\n      headingLevel = \"h2\",\n      preview = true,\n      delayDuration = 250,\n      loadingLines = 4,\n      emptyState,\n      errorMessage = \"The model stopped before it produced an answer.\",\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const uid = React.useId()\n    const headingId = `${uid}-query`\n    const rootRef = React.useRef<HTMLElement | null>(null)\n    const reduced = usePrefersReducedMotion()\n\n    /** Transient highlight: whatever the pointer or focus ring is on right now. */\n    const [hoverN, setHoverN] = React.useState<number | null>(null)\n    /** Sticky highlight: the chip somebody clicked. Survives the pointer leaving. */\n    const [pinnedN, setPinnedN] = React.useState<number | null>(null)\n\n    /** Identity of the CURRENT answer — a new question must not inherit the old pin. */\n    const answerToken = `${query}|${sources.map(source => source.id).join(\",\")}`\n    const [prevToken, setPrevToken] = React.useState(answerToken)\n    // Adjust-state-during-render rather than an effect: an effect would paint one\n    // frame with the previous answer's source highlighted.\n    if (prevToken !== answerToken) {\n      setPrevToken(answerToken)\n      setHoverN(null)\n      setPinnedN(null)\n    }\n\n    const activeN = hoverN ?? pinnedN\n    const sourceByN = React.useMemo(() => {\n      const map = new Map<number, AiAnswerSource>()\n      for (const source of sources) map.set(source.n, source)\n      return map\n    }, [sources])\n\n    // The pin is what scrolls the strip — a hover must never move the page under\n    // the pointer. Expansion is not a concern here: every source is always\n    // mounted, so the target node exists by the time this runs.\n    React.useEffect(() => {\n      if (pinnedN === null) return\n      const node = rootRef.current?.querySelector<HTMLElement>(`[data-source-n=\"${pinnedN}\"]`)\n      if (!node) return\n      node.scrollIntoView({\n        behavior: reduced ? \"auto\" : \"smooth\",\n        // `nearest` on both axes: the sources row scrolls, the page does not jump.\n        block: \"nearest\",\n        inline: \"nearest\",\n      })\n    }, [pinnedN, reduced])\n\n    const setRefs = (node: HTMLElement | null) => {\n      rootRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    }\n\n    const Heading = headingLevel as React.ElementType\n    const SubHeading = SUB_HEADING[headingLevel] as React.ElementType\n    const lines = Math.max(1, Math.floor(Number.isFinite(loadingLines) ? loadingLines : 4))\n    const blocks = React.useMemo(() => groupSegments(segments), [segments])\n    // `ready` with nothing written is the same screen as `empty` — except while\n    // streaming, where \"sources landed, prose hasn't\" is the whole point.\n    const isEmpty = status === \"empty\" || (segments.length === 0 && !streaming)\n\n    const rootClass = cn(\n      \"flex w-full min-w-0 flex-col gap-5 rounded-xl border bg-card p-5 text-card-foreground sm:p-6\",\n      className,\n    )\n    const sourceCardClass =\n      \"group flex h-full w-full min-w-0 cursor-pointer flex-col gap-1.5 rounded-lg border bg-background p-2.5 text-left transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n\n    const meta =\n      status === \"loading\"\n        ? \"Searching…\"\n        : status === \"error\"\n          ? \"The answer could not be produced.\"\n          : isEmpty\n            ? \"No answer for this question.\"\n            : streaming\n              ? \"Writing the answer…\"\n              : sources.length > 0\n                ? `Answer grounded in ${sources.length} source${sources.length === 1 ? \"\" : \"s\"}`\n                : \"Answered without external sources\"\n\n    const header = (\n      <header className=\"flex min-w-0 items-start gap-3\">\n        <span\n          aria-hidden=\"true\"\n          className=\"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border bg-muted\"\n        >\n          <Sparkles className=\"size-3.5 text-muted-foreground\" />\n        </span>\n        <div className=\"flex min-w-0 flex-col gap-1\">\n          <Heading className=\"min-w-0 text-base font-semibold leading-snug text-pretty sm:text-lg\" id={headingId}>\n            {query}\n          </Heading>\n          <p className=\"min-w-0 text-xs text-muted-foreground\" data-answer-meta=\"\">\n            {meta}\n          </p>\n        </div>\n      </header>\n    )\n\n    /* ------------------------------------------------------------ envelopes */\n\n    if (status === \"loading\") {\n      return (\n        <section\n          aria-busy=\"true\"\n          aria-labelledby={headingId}\n          className={rootClass}\n          ref={setRefs}\n          {...props}\n        >\n          {header}\n          <span className=\"sr-only\" role=\"status\">\n            Searching for sources\n          </span>\n          <div aria-hidden=\"true\" className=\"flex flex-col gap-3\">\n            <div className=\"h-2.5 w-16 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n            <div className=\"grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(min(11rem,100%),1fr))]\">\n              {Array.from({ length: 4 }, (_, index) => (\n                // Same two-level structure as a real card, so the ghosts occupy\n                // exactly the box the sources will land in.\n                <div className={cn(sourceCardClass, \"cursor-default\")} key={index}>\n                  <div className=\"flex items-center gap-2\">\n                    <div className=\"size-5 shrink-0 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                    <div className=\"h-2.5 w-14 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                  </div>\n                  <div className=\"h-2.5 w-full animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                  <div className=\"h-2.5 w-2/3 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                </div>\n              ))}\n            </div>\n          </div>\n          <SkeletonLines count={lines} widths={PROSE_WIDTHS} />\n        </section>\n      )\n    }\n\n    if (status === \"error\") {\n      return (\n        <section aria-labelledby={headingId} className={rootClass} ref={setRefs} {...props}>\n          {header}\n          <div\n            className=\"flex flex-col items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-4\"\n            role=\"alert\"\n          >\n            <p className=\"flex items-center gap-2 text-sm font-medium\">\n              <AlertCircle aria-hidden=\"true\" className=\"size-4 shrink-0 text-destructive\" />\n              Couldn&apos;t answer that\n            </p>\n            <p className=\"min-w-0 text-sm text-muted-foreground text-pretty\">{errorMessage}</p>\n            {onRetry && (\n              <button\n                className=\"mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n                data-retry=\"\"\n                onClick={onRetry}\n                type=\"button\"\n              >\n                <RefreshCcw aria-hidden=\"true\" className=\"size-3.5\" />\n                Try again\n              </button>\n            )}\n          </div>\n        </section>\n      )\n    }\n\n    /* ------------------------------------------------------------- handlers */\n\n    const handleCitationClick = (source: AiAnswerSource) => () => {\n      // Second click on the same chip unpins — a highlight you cannot switch off\n      // is a highlight that eventually lies about where you are.\n      setPinnedN(current => (current === source.n ? null : source.n))\n      onCitationSelect?.(source)\n    }\n\n    const handleSourceClick = (source: AiAnswerSource) => (event: React.MouseEvent<HTMLElement>) => {\n      if (!onSourceSelect) return\n      if (\n        event.defaultPrevented ||\n        event.metaKey ||\n        event.ctrlKey ||\n        event.shiftKey ||\n        event.altKey ||\n        event.button !== 0\n      ) {\n        return\n      }\n      event.preventDefault()\n      onSourceSelect(source, event)\n    }\n\n    const renderChips = (segment: AiAnswerSegment) =>\n      uniqueRefs(segment.citationRefs).map(n => {\n        const source = sourceByN.get(n)\n\n        if (!source) {\n          // The generator cited something the retriever never handed over. The\n          // number stays visible — it is what the model claimed — but it is not a\n          // button: an affordance that reveals nothing is worse than a footnote.\n          return (\n            <span\n              className={cn(NUMBER_PILL, \"relative -top-px ml-0.5 border-dashed align-middle text-muted-foreground\")}\n              data-citation={n}\n              data-unresolved=\"\"\n              key={n}\n            >\n              {n}\n              <span className=\"sr-only\"> (source unavailable)</span>\n            </span>\n          )\n        }\n\n        const active = activeN === n\n        const chip = (\n          <button\n            aria-label={`Source ${n}: ${source.title}, ${displayDomain(source.domain)}`}\n            aria-pressed={pinnedN === n}\n            className={cn(\n              NUMBER_PILL,\n              \"relative -top-px ml-0.5 cursor-pointer align-middle transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\",\n              active\n                ? \"border-primary bg-primary text-primary-foreground\"\n                : \"bg-muted text-muted-foreground hover:border-primary/50 hover:text-foreground\",\n            )}\n            data-citation={n}\n            key={n}\n            onBlur={() => setHoverN(current => (current === n ? null : current))}\n            onClick={handleCitationClick(source)}\n            onFocus={() => setHoverN(n)}\n            onPointerEnter={() => setHoverN(n)}\n            onPointerLeave={() => setHoverN(current => (current === n ? null : current))}\n            type=\"button\"\n          >\n            {n}\n          </button>\n        )\n\n        if (!preview) return chip\n\n        return (\n          <Tooltip key={n}>\n            <TooltipTrigger asChild>{chip}</TooltipTrigger>\n            {/* Read-only content only — a tooltip you cannot put a pointer inside\n                must never hold something clickable. Clicking the chip is what\n                takes you to the card. */}\n            <TooltipContent className=\"block max-w-sm p-3\" collisionPadding={8} sideOffset={6}>\n              <SourcePreview source={source} />\n            </TooltipContent>\n          </Tooltip>\n        )\n      })\n\n    /** A sentence lights up when its source is the one under the pointer. */\n    const segmentClass = (segment: AiAnswerSegment) =>\n      cn(\n        \"-mx-0.5 rounded-sm px-0.5 transition-colors motion-reduce:transition-none\",\n        activeN !== null && segment.citationRefs.includes(activeN) && \"bg-primary/10\",\n      )\n\n    const sourcesRow = sources.length > 0 && (\n      <div className=\"flex min-w-0 flex-col gap-2\">\n        <p className=\"flex items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n          <BookOpen aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n          Sources\n          <span className=\"tabular-nums\" data-source-count={sources.length}>\n            · {sources.length}\n          </span>\n        </p>\n        {/* A wrapping grid, not a scroller: every retrieved source stays visible\n            at every width instead of hiding past the right edge. */}\n        <ol className=\"grid list-none gap-2 [grid-template-columns:repeat(auto-fit,minmax(min(11rem,100%),1fr))]\">\n          {sources.map(source => {\n            const active = activeN === source.n\n            return (\n              <li className=\"min-w-0\" key={source.id}>\n                <a\n                  className={cn(sourceCardClass, active && \"border-primary/60 bg-muted/60 ring-2 ring-ring\")}\n                  data-active={active || undefined}\n                  data-source-n={source.n}\n                  href={source.url}\n                  onBlur={() => setHoverN(current => (current === source.n ? null : current))}\n                  onClick={handleSourceClick(source)}\n                  onFocus={() => setHoverN(source.n)}\n                  onPointerEnter={() => setHoverN(source.n)}\n                  onPointerLeave={() => setHoverN(current => (current === source.n ? null : current))}\n                  rel={target === \"_blank\" ? \"noreferrer\" : undefined}\n                  target={target}\n                >\n                  <span className=\"flex min-w-0 items-center gap-2\">\n                    <SourceFavicon source={source} />\n                    <span className=\"min-w-0 flex-1 truncate text-[11px] text-muted-foreground\">\n                      {displayDomain(source.domain)}\n                    </span>\n                    <span\n                      className={cn(\n                        NUMBER_PILL,\n                        active ? \"border-primary bg-primary text-primary-foreground\" : \"bg-muted text-muted-foreground\",\n                      )}\n                    >\n                      {source.n}\n                    </span>\n                  </span>\n                  <span className=\"line-clamp-2 min-w-0 text-xs font-medium leading-snug\">{source.title}</span>\n                  <ArrowUpRight\n                    aria-hidden=\"true\"\n                    className=\"size-3.5 shrink-0 self-end text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none\"\n                  />\n                </a>\n              </li>\n            )\n          })}\n        </ol>\n      </div>\n    )\n\n    const followUpBlock = onFollowUp && !streaming && followUps.length > 0 && (\n      <div className=\"flex min-w-0 flex-col gap-2 border-t pt-4\">\n        <p className=\"text-xs font-medium text-muted-foreground\">Keep going</p>\n        <ul className=\"flex list-none flex-col gap-1.5\">\n          {followUps.map(followUp => (\n            <li className=\"min-w-0\" key={followUp.id}>\n              <button\n                className=\"group flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n                data-follow-up={followUp.id}\n                onClick={() => onFollowUp(followUp)}\n                type=\"button\"\n              >\n                <span className=\"min-w-0 text-pretty\">{followUp.text}</span>\n                <ArrowUpRight\n                  aria-hidden=\"true\"\n                  className=\"size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none\"\n                />\n              </button>\n            </li>\n          ))}\n        </ul>\n      </div>\n    )\n\n    /* ---------------------------------------------------------------- empty */\n\n    if (isEmpty) {\n      return (\n        <TooltipProvider delayDuration={delayDuration}>\n          <section aria-labelledby={headingId} className={rootClass} ref={setRefs} {...props}>\n            {header}\n            {sourcesRow}\n            {emptyState ?? (\n              <p className=\"flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground\">\n                <SearchX aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n                Nothing in the retrieved material answers this — rather than guess, the model said nothing.\n              </p>\n            )}\n            {/* Follow-ups matter MOST here: an empty answer with no way forward is\n                a dead end, and a narrower question is usually the way out. */}\n            {followUpBlock}\n          </section>\n        </TooltipProvider>\n      )\n    }\n\n    /* ---------------------------------------------------------------- ready */\n\n    const lastIndex = blocks.length - 1\n\n    return (\n      <TooltipProvider delayDuration={delayDuration}>\n        <section\n          aria-busy={streaming || undefined}\n          aria-labelledby={headingId}\n          className={rootClass}\n          ref={setRefs}\n          {...props}\n        >\n          {header}\n          {sourcesRow}\n\n          <div className=\"flex min-w-0 flex-col gap-3 text-sm leading-7\">\n            {streaming && (\n              <span className=\"sr-only\" role=\"status\">\n                The answer is still being written.\n              </span>\n            )}\n\n            {blocks.map((block, index) => {\n              const showCaret = streaming && index === lastIndex\n\n              if (block.kind === \"heading\") {\n                return (\n                  <SubHeading className=\"text-sm font-semibold leading-6\" data-block=\"heading\" key={block.key}>\n                    {block.segment.text}\n                    {showCaret && <Caret />}\n                  </SubHeading>\n                )\n              }\n\n              if (block.kind === \"list\") {\n                return (\n                  <ul className=\"flex list-none flex-col gap-1.5\" data-block=\"list\" key={block.key}>\n                    {block.segments.map((segment, itemIndex) => (\n                      <li className=\"flex min-w-0 items-start gap-2\" key={segment.id}>\n                        <span aria-hidden=\"true\" className=\"mt-3 size-1 shrink-0 rounded-full bg-muted-foreground\" />\n                        <span className=\"min-w-0 text-pretty\">\n                          <span className={segmentClass(segment)} data-segment={segment.id}>\n                            {segment.text}\n                          </span>\n                          {renderChips(segment)}\n                          {showCaret && itemIndex === block.segments.length - 1 && <Caret />}\n                        </span>\n                      </li>\n                    ))}\n                  </ul>\n                )\n              }\n\n              return (\n                <p className=\"min-w-0 text-pretty\" data-block=\"paragraph\" key={block.key}>\n                  {block.segments.map((segment, itemIndex) => (\n                    <React.Fragment key={segment.id}>\n                      {itemIndex > 0 && \" \"}\n                      <span className={segmentClass(segment)} data-segment={segment.id}>\n                        {segment.text}\n                      </span>\n                      {renderChips(segment)}\n                    </React.Fragment>\n                  ))}\n                  {showCaret && <Caret />}\n                </p>\n              )\n            })}\n\n            {/* Two ghost lines while streaming: the box stops jumping every time a\n                sentence lands, and the reader can see there is more coming. */}\n            {streaming && <SkeletonLines count={2} widths={PROSE_WIDTHS} />}\n          </div>\n\n          {followUpBlock}\n        </section>\n      </TooltipProvider>\n    )\n  },\n)\n\nAiAnswer.displayName = \"AiAnswer\"\n\nexport default AiAnswer\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/ai-answer.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Envelope state of ONE answer render. It describes whether the answer arrived,\n * never how good it is: a confidently wrong answer is still `ready`.\n */\nexport const aiAnswerStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\n\n/**\n * One retrieved source. `n` is the citation NUMBER the generator assigned, and it\n * is what the prose points at — deliberately not the array index, so filtering or\n * re-ordering this list for display can never renumber the answer.\n */\nexport const aiAnswerSourceSchema = z.object({\n  id: z.string(),\n  /** Citation number, authored upstream. Must match the `citationRefs` in the prose. */\n  n: z.number().int().positive(),\n  title: z.string(),\n  /** Absolute URL — the card is a real link, so a placeholder here ships a dead link. */\n  url: z.string(),\n  /** Host as you want it read, e.g. \"docs.stripe.com\". `www.` and schemes are stripped for display. */\n  domain: z.string(),\n  /** The retrieved excerpt, revealed on hover/focus. Optional — the preview simply has less to say. */\n  snippet: z.string().optional(),\n  /**\n   * Supplied by the caller. The component never guesses a third-party favicon\n   * endpoint (that would hand every source your readers open to someone else's\n   * server); with no URL it draws a monogram from the domain.\n   */\n  faviconUrl: z.string().optional(),\n})\n\n/**\n * One unit of answer prose — usually a sentence, because a sentence is the unit a\n * citation actually attaches to. Consecutive `text` segments flow into the SAME\n * paragraph, so the answer reads as prose rather than a stack of one-line blocks.\n */\nexport const aiAnswerSegmentSchema = z.object({\n  id: z.string(),\n  /** `text` (default) flows inline, `bullet` joins the adjacent list, `heading` opens a section. */\n  kind: z.enum([\"text\", \"bullet\", \"heading\"]).optional(),\n  text: z.string(),\n  /** Citation numbers cited by THIS segment. Values are matched against `source.n`. */\n  citationRefs: z.array(z.number().int().positive()),\n  /** Force a paragraph break before this segment; otherwise adjacent `text` segments merge. */\n  startsParagraph: z.boolean().optional(),\n})\n\n/** A suggested next question. Rendered only when the host wires `onFollowUp`. */\nexport const aiAnswerFollowUpSchema = z.object({\n  id: z.string(),\n  text: z.string(),\n})\n\nexport const aiAnswerSchema = z.object({\n  status: aiAnswerStatusSchema,\n  /** The question being answered — echoed as the section heading, including while loading. */\n  query: z.string(),\n  segments: z.array(aiAnswerSegmentSchema),\n  sources: z.array(aiAnswerSourceSchema),\n  followUps: z.array(aiAnswerFollowUpSchema),\n  /**\n   * Tokens are still arriving. Orthogonal to `status`: `loading` is \"nothing yet\",\n   * `ready` + `streaming` is \"sources landed, prose is still being written\".\n   */\n  streaming: z.boolean().optional(),\n})\n\nexport type AiAnswerStatus = z.infer<typeof aiAnswerStatusSchema>\nexport type AiAnswerSource = z.infer<typeof aiAnswerSourceSchema>\nexport type AiAnswerSegment = z.infer<typeof aiAnswerSegmentSchema>\nexport type AiAnswerFollowUp = z.infer<typeof aiAnswerFollowUpSchema>\nexport type AiAnswerData = z.infer<typeof aiAnswerSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:block"
}