{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "title": "Streaming text",
  "description": "Paced response text with inline citations, sources, actions, follow-ups, and reduced-motion behavior.",
  "registryDependencies": [
    "https://beadkit.dev/r/ai-core.json"
  ],
  "files": [
    {
      "path": "components/ai/StreamingText.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useState, type ReactNode } from \"react\";\nimport styles from \"./ai-state.module.css\";\nimport { useReducedMotion } from \"./useReducedMotion\";\nimport { safeExternalHref } from \"./safeExternalHref\";\n\nexport interface StreamingTextSource {\n  name: string;\n  domain?: string;\n  href?: string;\n  imageUrl?: string;\n}\n\nexport interface StreamingTextToken {\n  /** Text is revealed word by word. */\n  text?: string;\n  /** A source chip is revealed at this position. */\n  citation?: StreamingTextSource;\n}\n\nexport interface StreamingTextAction {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n}\n\nexport interface StreamingTextProps {\n  tokens: readonly StreamingTextToken[];\n  sources?: readonly StreamingTextSource[];\n  actions?: readonly StreamingTextAction[];\n  followUps?: readonly string[];\n  onAction?: (action: StreamingTextAction) => void;\n  onFollowUp?: (prompt: string) => void;\n  wordIntervalMs?: number;\n  holdMs?: number;\n  loop?: boolean;\n  autoStart?: boolean;\n  className?: string;\n}\n\ntype Unit = { text?: string; citation?: StreamingTextSource };\n\nfunction splitTokens(tokens: readonly StreamingTextToken[]): Unit[] {\n  return tokens.flatMap((token): Unit[] => {\n    if (token.citation) return [{ citation: token.citation }];\n    if (!token.text) return [];\n    return token.text.trim().split(/\\s+/).map((text) => ({ text }));\n  });\n}\n\nfunction SourceChip({ source }: { source: StreamingTextSource }) {\n  const href = safeExternalHref(source.href);\n  const content = (\n    <>\n      <span className=\"size-3 shrink-0 rounded-full bg-accent\" aria-hidden=\"true\" />\n      {source.domain ?? source.name}\n    </>\n  );\n  const className =\n    \"mx-0.5 inline-flex h-5 translate-y-[-1px] items-center gap-1 rounded-md bg-muted px-1.5 align-middle font-mono text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground motion-reduce:transition-none\";\n  return href ? (\n    <a href={href} target=\"_blank\" rel=\"noreferrer\" className={className}>\n      {content}\n    </a>\n  ) : (\n    <span className={className}>{content}</span>\n  );\n}\n\nexport function StreamingText({\n  tokens,\n  sources = [],\n  actions = [],\n  followUps = [],\n  onAction,\n  onFollowUp,\n  wordIntervalMs = 55,\n  holdMs = 3400,\n  loop = false,\n  autoStart = true,\n  className = \"\",\n}: StreamingTextProps) {\n  const units = useMemo(() => splitTokens(tokens), [tokens]);\n  const contentKey = units\n    .map((unit) => unit.text ?? `${unit.citation?.name}:${unit.citation?.href ?? \"\"}`)\n    .join(\"\\u0000\");\n  const reducedMotion = useReducedMotion();\n  const [count, setCount] = useState(autoStart ? 0 : units.length);\n  const [sourcesOpen, setSourcesOpen] = useState(false);\n  const visibleCount = reducedMotion ? units.length : count;\n  const done = visibleCount >= units.length;\n\n  useEffect(() => {\n    setCount(autoStart ? 0 : units.length);\n    setSourcesOpen(false);\n  }, [autoStart, contentKey, units.length]);\n\n  useEffect(() => {\n    if (reducedMotion || !autoStart || units.length === 0 || (!loop && done)) return;\n    const timer = window.setTimeout(() => {\n      if (done) setCount(loop ? 0 : units.length);\n      else setCount((value) => Math.min(units.length, value + 1));\n    }, done ? holdMs : wordIntervalMs);\n    return () => window.clearTimeout(timer);\n  }, [autoStart, done, holdMs, loop, reducedMotion, units.length, wordIntervalMs, count]);\n\n  return <div data-ai-state=\"streaming-text\" className={`min-h-62 w-full max-w-md ${className}`}>\n    <p className=\"text-sm leading-relaxed\">\n      {units.slice(0, visibleCount).map((unit, index) => unit.citation ? <SourceChip key={index} source={unit.citation} /> : <span key={index} className={`${styles.fadeIn} inline`}>{unit.text} </span>)}\n      {!done ? <span aria-label=\"Streaming\" className=\"ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-foreground\" /> : null}\n    </p>\n    <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n      {done ? \"Response complete.\" : \"Response streaming.\"}\n    </span>\n\n    {actions.length > 0 || sources.length > 0 ? <div className={`mt-2 flex items-center gap-0.5 transition-opacity duration-300 ${done ? \"opacity-100\" : \"pointer-events-none opacity-0\"}`}>\n      {actions.map((action) => <button key={action.id} type=\"button\" aria-label={action.label} disabled={!done} onClick={() => onAction?.(action)} className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\">{action.icon ?? <span aria-hidden=\"true\">•</span>}</button>)}\n      {sources.length > 0 ? <button type=\"button\" aria-expanded={sourcesOpen} disabled={!done} onClick={() => setSourcesOpen((value) => !value)} className=\"ml-1.5 flex items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-muted-foreground transition-colors hover:bg-muted disabled:cursor-default\">\n        <span aria-hidden=\"true\" className=\"flex -space-x-1\">{sources.slice(0, 3).map((source) => <span key={source.name} className=\"size-3.5 rounded-full bg-accent ring-1 ring-card\" />)}</span>{sources.length} {sources.length === 1 ? \"source\" : \"sources\"}\n      </button> : null}\n    </div> : null}\n\n    {sources.length > 0 ? <div className={`${styles.motion} grid transition-[grid-template-rows,opacity]`} style={{ gridTemplateRows: done && sourcesOpen ? \"1fr\" : \"0fr\", opacity: done && sourcesOpen ? 1 : 0 }} aria-hidden={!done || !sourcesOpen} inert={!done || !sourcesOpen ? true : undefined}>\n      <div className=\"overflow-hidden\"><div className=\"mt-1.5 flex flex-col rounded-lg bg-muted p-1\">\n        {sources.map((source) => {\n          const href = safeExternalHref(source.href);\n          const content = <><span className=\"size-4 rounded bg-accent\" aria-hidden=\"true\" /><span>{source.name}</span>{source.domain ? <span className=\"ml-auto font-mono text-[10px]\">{source.domain}</span> : null}</>;\n          const rowClass = \"flex items-center gap-2 rounded-md px-1.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground motion-reduce:transition-none\";\n          return href ? <a key={`${source.name}:${href}`} href={href} target=\"_blank\" rel=\"noreferrer\" className={rowClass}>{content}</a> : <span key={source.name} className={rowClass}>{content}</span>;\n        })}\n      </div></div>\n    </div> : null}\n\n    {followUps.length > 0 ? <div className={`mt-3 transition-opacity duration-300 ${done ? \"opacity-100\" : \"pointer-events-none opacity-0\"}`}>\n      <p className=\"text-xs font-medium text-muted-foreground\">Follow-ups</p>\n      <div className=\"mt-0.5 flex flex-col\">{followUps.map((prompt) => <button key={prompt} type=\"button\" disabled={!done} onClick={() => onFollowUp?.(prompt)} className=\"flex items-center gap-2 border-b border-border px-1.5 py-1.5 text-left text-sm transition-colors hover:bg-muted disabled:cursor-default\"><span aria-hidden=\"true\" className=\"text-muted-foreground\">↪</span>{prompt}</button>)}</div>\n    </div> : null}\n  </div>;\n}\n\nexport default StreamingText;\n",
      "type": "registry:component",
      "target": "@components/ai/StreamingText.tsx"
    }
  ],
  "type": "registry:component"
}
