{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-message","type":"registry:ui","title":"Streaming Message","description":"Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.","author":"Scrim UI (https://scrimui.dev)","categories":["messages"],"docs":"https://scrimui.dev/components/streaming-message","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/streaming-message/streaming-message.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type StreamingMessageProps = {\n  text: string;\n  isStreaming?: boolean;\n  stopped?: boolean;\n  speed?: number;\n  onStop?: () => void;\n  onRegenerate?: () => void;\n  onComplete?: () => void;\n  showActions?: boolean;\n  avatar?: React.ReactNode;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Caret                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction Caret() {\n  return (\n    <>\n      <style>{`@keyframes aiui-caret{50%{opacity:0}}`}</style>\n      <span\n        aria-hidden\n        className=\"ml-0.5 inline-block h-[1em] w-[2px] translate-y-[2px] rounded-full bg-current\"\n        style={{ animation: \"aiui-caret 1s steps(1) infinite\" }}\n      />\n    </>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* StreamingMessage                                                    */\n/* ------------------------------------------------------------------ */\n\nexport function StreamingMessage({\n  text,\n  isStreaming = false,\n  stopped = false,\n  speed = 1,\n  onStop,\n  onRegenerate,\n  onComplete,\n  showActions = true,\n  avatar,\n  className = \"\",\n}: StreamingMessageProps) {\n  const [count, setCount] = React.useState(isStreaming ? 0 : text.length);\n  const doneRef = React.useRef(false);\n\n  /* Adjust state during render whenever the target text or flag changes,\n     so the reveal resets without a setState-in-effect */\n  const [prev, setPrev] = React.useState<{ text: string; isStreaming: boolean }>({\n    text,\n    isStreaming,\n  });\n  if (prev.text !== text || prev.isStreaming !== isStreaming) {\n    setPrev({ text, isStreaming });\n    setCount(isStreaming ? 0 : text.length);\n  }\n\n  /* Reveal loop */\n  React.useEffect(() => {\n    if (!isStreaming) return;\n    doneRef.current = false;\n    const tick = window.setInterval(() => {\n      setCount((c) => Math.min(c + speed, text.length));\n    }, 16);\n    return () => window.clearInterval(tick);\n  }, [isStreaming, text, speed]);\n\n  /* Fire onComplete once when the reveal finishes */\n  React.useEffect(() => {\n    if (isStreaming && count >= text.length && !doneRef.current) {\n      doneRef.current = true;\n      onComplete?.();\n    }\n  }, [isStreaming, count, text, onComplete]);\n\n  const displayed = text.slice(0, count);\n\n  return (\n    <div className={`flex items-start gap-3 ${className}`}>\n      {avatar ?? (\n        <div className=\"flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-lg bg-zinc-900 text-xs font-semibold text-white dark:bg-zinc-100 dark:text-zinc-900\">\n          AI\n        </div>\n      )}\n\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <span className=\"text-sm font-medium\">Assistant</span>\n          {isStreaming && (\n            <span className=\"inline-flex items-center gap-1 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400\">\n              <span className=\"h-1.5 w-1.5 animate-pulse rounded-full bg-current\" />\n              Generating\n            </span>\n          )}\n          {stopped && (\n            <span className=\"rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400\">\n              Stopped generating\n            </span>\n          )}\n        </div>\n\n        <div className=\"mt-1.5 whitespace-pre-wrap rounded-2xl rounded-tl-md border border-zinc-200 bg-zinc-50 px-4 py-3 text-[15px] leading-6 text-zinc-800 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-100\">\n          {displayed}\n          {isStreaming && <Caret />}\n        </div>\n\n        {/* Actions row */}\n        {!isStreaming && showActions && onRegenerate && (\n          <div className=\"mt-2 flex items-center gap-1\">\n            <button\n              type=\"button\"\n              onClick={onRegenerate}\n              className=\"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-xs text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n            >\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"13\" height=\"13\">\n                <path d=\"M21 12a9 9 0 1 1-2.64-6.36L21 8\" />\n                <path d=\"M21 3v5h-5\" />\n              </svg>\n              Regenerate\n            </button>\n          </div>\n        )}\n\n        {/* Stop pill */}\n        {isStreaming && onStop && (\n          <button\n            type=\"button\"\n            onClick={onStop}\n            className=\"mt-2 inline-flex h-7 items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 text-xs text-zinc-600 transition-colors hover:bg-zinc-100 dark:border-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n          >\n            <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"10\" height=\"10\">\n              <rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"2\" />\n            </svg>\n            Stop generating\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/streaming-message.tsx"}]}