{
  "schemaVersion": 1,
  "target": "react",
  "name": "streaming-message",
  "version": "0.2.0",
  "category": "agent",
  "title": "Streaming Message",
  "description": "Chat messages that stream in as Markdown, plus a scroller that follows new text without stealing the reader's scroll position.",
  "tags": [
    "agent",
    "chat",
    "message",
    "streaming",
    "markdown",
    "scroll"
  ],
  "requires": {
    "projectTypes": [
      "next",
      "vite-react"
    ],
    "styling": "tailwind-v4"
  },
  "npmDependencies": {},
  "registryDependencies": [
    {
      "name": "agent-run",
      "version": "0.1.1"
    }
  ],
  "docsPath": "/components/streaming-message",
  "props": [
    {
      "name": "role",
      "type": "\"user\" | \"assistant\"",
      "description": "Who wrote the message."
    },
    {
      "name": "content",
      "type": "string",
      "description": "Markdown text. Raw HTML is shown as text and never executed; links allow only http(s), mailto, and relative URLs."
    },
    {
      "name": "status",
      "type": "\"streaming\" | \"complete\" | \"failed\"",
      "default": "\"complete\"",
      "description": "Streaming shows a caret after the last word; failed keeps the partial text and shows the error."
    },
    {
      "name": "MessageScroller.contentKey",
      "type": "unknown",
      "description": "Changes when content grows. The scroller follows new content only while the reader is at the bottom."
    }
  ],
  "accessibility": [
    "MessageScroller is a log region that announces new messages politely.",
    "Scrolling up keeps the reader's place; a Jump to latest button returns to the newest message.",
    "Jump to latest scrolls instantly when reduced motion is preferred, and the caret holds still.",
    "Failures are announced with role=alert and keep the partial text."
  ],
  "states": [
    "streaming",
    "complete",
    "failed"
  ],
  "files": [
    {
      "path": "agent/streaming-message.tsx",
      "content": "\"use client\";\n\nimport { type ComponentProps, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { MessageStatus } from \"./agent-run\";\nimport { Markdown } from \"./markdown\";\n\nexport interface StreamingMessageProps extends Omit<ComponentProps<\"article\">, \"children\"> {\n  role: \"user\" | \"assistant\";\n  /** Message text, as Markdown. Raw HTML is shown as text, never executed. */\n  content: string;\n  status?: MessageStatus;\n  /** Shown when status is \"failed\". */\n  error?: ReactNode;\n  author?: ReactNode;\n}\n\n/**\n * One chat message. User messages sit in a quiet tinted bubble; assistant replies are\n * open text. While streaming, a caret follows the last word; failures keep the partial text.\n */\nexport function StreamingMessage({ role, content, status = \"complete\", error, author, className, ...props }: StreamingMessageProps) {\n  const isUser = role === \"user\";\n  const streaming = status === \"streaming\";\n  const caret = streaming ? (\n    <span\n      aria-hidden=\"true\"\n      className=\"ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[0.2em] rounded-full bg-sky-600 motion-safe:animate-pulse dark:bg-sky-400\"\n    />\n  ) : null;\n\n  return (\n    <article\n      data-role={role}\n      data-status={status}\n      aria-busy={streaming || undefined}\n      className={[\n        \"flex transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none\",\n        isUser ? \"justify-end\" : \"justify-start\",\n        className,\n      ]\n        .filter(Boolean)\n        .join(\" \")}\n      {...props}\n    >\n      <div\n        className={[\n          \"min-w-0 text-sm leading-relaxed text-zinc-950 dark:text-white\",\n          isUser\n            ? \"max-w-[85%] rounded-2xl rounded-br-md border border-zinc-950/[0.08] bg-zinc-950/[0.04] px-3.5 py-2 dark:border-white/15 dark:bg-black\"\n            : \"w-full max-w-prose py-0.5\",\n        ].join(\" \")}\n      >\n        <p className=\"sr-only\">{author ?? (isUser ? \"You\" : \"Assistant\")}:</p>\n        {content ? (\n          <Markdown source={content} trailing={caret} />\n        ) : streaming ? (\n          <p className=\"flex items-center gap-2 text-zinc-500\">\n            <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" className=\"size-3.5 text-sky-600 dark:text-sky-400\">\n              <circle cx=\"8\" cy=\"8\" r=\"6.25\" strokeOpacity=\"0.2\" />\n              <path d=\"M8 1.75A6.25 6.25 0 1 1 1.75 8\" className=\"origin-center motion-safe:animate-spin\" />\n            </svg>\n            Thinking\n          </p>\n        ) : null}\n        {status === \"failed\" ? (\n          <p\n            role=\"alert\"\n            className=\"mt-3 flex items-start gap-2 rounded-lg border border-red-500/35 bg-red-500/[0.08] px-3 py-2 text-sm text-red-700 transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none dark:text-red-300\"\n          >\n            <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" className=\"mt-0.5 size-4 shrink-0\">\n              <circle cx=\"8\" cy=\"8\" r=\"6.25\" />\n              <path d=\"m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8\" />\n            </svg>\n            <span>{error ?? \"The response stopped before it finished.\"}</span>\n          </p>\n        ) : null}\n      </div>\n    </article>\n  );\n}\n\nexport interface MessageScrollerProps extends ComponentProps<\"div\"> {\n  /** Changes whenever content grows (e.g. total characters); used to follow new text. */\n  contentKey: unknown;\n}\n\n/**\n * A scrolling message list that follows new content only while the reader is at the\n * bottom. Scrolling up keeps their place and shows a \"Jump to latest\" button.\n */\nexport function MessageScroller({ contentKey, className, children, ...props }: MessageScrollerProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [following, setFollowing] = useState(true);\n  const followingRef = useRef(true);\n\n  const onScroll = useCallback(() => {\n    const element = ref.current;\n    if (!element) return;\n    const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 24;\n    followingRef.current = atBottom;\n    setFollowing(atBottom);\n  }, []);\n\n  useLayoutEffect(() => {\n    const element = ref.current;\n    if (element && followingRef.current) element.scrollTop = element.scrollHeight;\n  }, [contentKey]);\n\n  useEffect(() => {\n    const element = ref.current;\n    element?.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => element?.removeEventListener(\"scroll\", onScroll);\n  }, [onScroll]);\n\n  function jumpToLatest() {\n    const element = ref.current;\n    if (!element) return;\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    element.scrollTo({ top: element.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n    followingRef.current = true;\n    setFollowing(true);\n  }\n\n  return (\n    <div className=\"relative min-h-0\">\n      <div ref={ref} role=\"log\" aria-live=\"polite\" aria-relevant=\"additions\" className={[\"h-full overflow-y-auto\", className].filter(Boolean).join(\" \")} {...props}>\n        {children}\n      </div>\n      {following ? null : (\n        <button\n          type=\"button\"\n          onClick={jumpToLatest}\n          className={[\n            \"absolute bottom-3 left-1/2 inline-flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-lg border border-zinc-950/10 bg-white px-3 text-xs font-medium text-zinc-800\",\n            \"shadow-[0_12px_32px_-8px_rgb(0_0_0/0.18)] transition-[opacity,translate,background-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0\",\n            \"hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none\",\n            \"dark:border-white/15 dark:bg-black dark:text-zinc-200 dark:shadow-[0_16px_48px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)] dark:hover:border-white/25\",\n          ].join(\" \")}\n        >\n          Jump to latest\n          <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-3.5\">\n            <path d=\"M8 3v10M4 9l4 4 4-4\" />\n          </svg>\n        </button>\n      )}\n    </div>\n  );\n}\n"
    },
    {
      "path": "agent/markdown.tsx",
      "content": "import { Fragment, type ReactNode } from \"react\";\n\ntype Block =\n  | { type: \"code\"; language: string; code: string }\n  | { type: \"heading\"; level: number; text: string }\n  | { type: \"list\"; ordered: boolean; items: string[] }\n  | { type: \"paragraph\"; text: string };\n\nexport interface MarkdownProps {\n  source: string;\n  /** Rendered at the end of the last line of text, e.g. a streaming caret. */\n  trailing?: ReactNode;\n}\n\n/**\n * Renders a safe subset of Markdown as React elements: paragraphs, headings, lists,\n * fenced code, inline code, bold, italic, and links. Raw HTML is shown as text and\n * never executed; links are limited to http(s), mailto, and relative URLs.\n * Unfinished syntax (common while streaming) renders as plain text.\n */\nexport function Markdown({ source, trailing }: MarkdownProps) {\n  const blocks = parse(source);\n  const last = blocks.length - 1;\n  // A caret after a code block would sit inside preformatted text, so it goes below instead.\n  const trailingInline = trailing != null && last >= 0 && blocks[last]!.type !== \"code\";\n\n  return (\n    <div className=\"grid gap-3\">\n      {blocks.map((block, index) => {\n        const tail = trailingInline && index === last ? trailing : null;\n        switch (block.type) {\n          case \"code\":\n            return (\n              <pre\n                key={index}\n                className=\"overflow-x-auto rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed dark:border-white/[0.08] dark:bg-transparent\"\n              >\n                <code data-language={block.language || undefined}>{block.code}</code>\n              </pre>\n            );\n          case \"heading\":\n            return (\n              <p key={index} role=\"heading\" aria-level={Math.min(block.level + 3, 6)} className=\"font-semibold\">\n                {inline(block.text)}\n                {tail}\n              </p>\n            );\n          case \"list\": {\n            const List = block.ordered ? \"ol\" : \"ul\";\n            return (\n              <List\n                key={index}\n                className={`${block.ordered ? \"list-decimal\" : \"list-disc\"} space-y-1 pl-5 marker:text-zinc-400 dark:marker:text-zinc-600`}\n              >\n                {block.items.map((item, i) => (\n                  <li key={i} className=\"pl-0.5\">\n                    {inline(item)}\n                    {i === block.items.length - 1 ? tail : null}\n                  </li>\n                ))}\n              </List>\n            );\n          }\n          default:\n            return (\n              <p key={index}>\n                {inline(block.text)}\n                {tail}\n              </p>\n            );\n        }\n      })}\n      {trailing != null && !trailingInline ? <p>{trailing}</p> : null}\n    </div>\n  );\n}\n\nconst LIST = /^\\s*([-*]|\\d+\\.)\\s+/;\n\nfunction parse(source: string): Block[] {\n  const blocks: Block[] = [];\n  const lines = source.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n  let index = 0;\n\n  while (index < lines.length) {\n    const line = lines[index]!;\n    const fence = line.match(/^```\\s*([\\w-]*)\\s*$/);\n    if (fence) {\n      const code: string[] = [];\n      index++;\n      while (index < lines.length && !/^```\\s*$/.test(lines[index]!)) code.push(lines[index++]!);\n      index++;\n      blocks.push({ type: \"code\", language: fence[1] ?? \"\", code: code.join(\"\\n\") });\n      continue;\n    }\n    const heading = line.match(/^(#{1,3})\\s+(.*)$/);\n    if (heading) {\n      blocks.push({ type: \"heading\", level: heading[1]!.length, text: heading[2]! });\n      index++;\n      continue;\n    }\n    if (LIST.test(line)) {\n      const ordered = /^\\s*\\d+\\./.test(line);\n      const items: string[] = [];\n      while (index < lines.length && LIST.test(lines[index]!)) items.push(lines[index++]!.replace(LIST, \"\"));\n      blocks.push({ type: \"list\", ordered, items });\n      continue;\n    }\n    if (!line.trim()) {\n      index++;\n      continue;\n    }\n    const paragraph: string[] = [];\n    while (index < lines.length && lines[index]!.trim() && !/^(```|#{1,3}\\s|\\s*([-*]|\\d+\\.)\\s)/.test(lines[index]!)) paragraph.push(lines[index++]!);\n    blocks.push({ type: \"paragraph\", text: paragraph.join(\" \") });\n  }\n  return blocks;\n}\n\nconst INLINE = /(`[^`]+`|\\*\\*[^*]+\\*\\*|\\*[^*\\s][^*]*\\*|\\[[^\\]]+\\]\\([^)\\s]+\\))/g;\n\nfunction inline(text: string): ReactNode {\n  return text.split(INLINE).map((part, i) => {\n    if (part.startsWith(\"`\") && part.endsWith(\"`\") && part.length > 2) {\n      return (\n        <code\n          key={i}\n          className=\"rounded-md border border-zinc-950/[0.08] bg-zinc-950/[0.03] px-1 py-px font-mono text-[0.85em] dark:border-white/12 dark:bg-transparent\"\n        >\n          {part.slice(1, -1)}\n        </code>\n      );\n    }\n    if (part.startsWith(\"**\") && part.endsWith(\"**\") && part.length > 4)\n      return (\n        <strong key={i} className=\"font-semibold\">\n          {part.slice(2, -2)}\n        </strong>\n      );\n    if (part.startsWith(\"*\") && part.endsWith(\"*\") && part.length > 2) return <em key={i}>{part.slice(1, -1)}</em>;\n    const link = part.match(/^\\[([^\\]]+)\\]\\(([^)\\s]+)\\)$/);\n    if (link) {\n      const href = safeHref(link[2]!);\n      return href ? (\n        <a\n          key={i}\n          href={href}\n          className=\"rounded-sm font-medium underline decoration-current/30 underline-offset-[3px] transition-[text-decoration-color] duration-150 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none\"\n          rel=\"noopener noreferrer nofollow\"\n          target={href.startsWith(\"/\") ? undefined : \"_blank\"}\n        >\n          {link[1]}\n        </a>\n      ) : (\n        <Fragment key={i}>{link[1]}</Fragment>\n      );\n    }\n    return <Fragment key={i}>{part}</Fragment>;\n  });\n}\n\nfunction safeHref(href: string): string | null {\n  if (href.startsWith(\"/\") && !href.startsWith(\"//\")) return href;\n  try {\n    const url = new URL(href);\n    return [\"http:\", \"https:\", \"mailto:\"].includes(url.protocol) ? url.toString() : null;\n  } catch {\n    return null;\n  }\n}\n"
    }
  ],
  "example": {
    "content": "import { StreamingMessage } from \"@/components/seamless/agent/streaming-message\";\n\nexport default function StreamingMessageExample() {\n  return (\n    <div className=\"grid w-full max-w-md gap-3\">\n      <StreamingMessage role=\"user\" content=\"What are the new rate limits?\" />\n      <StreamingMessage role=\"assistant\" status=\"streaming\" content={\"Free plans allow **60 requests/min**. Pro plans allow\"} />\n    </div>\n  );\n}\n"
  }
}
