{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-bubble",
  "title": "Message Bubble",
  "description": "A focused conversational surface with distinct user, assistant, and system treatments.",
  "dependencies": [
    "cn@^0.3.0",
    "motion@^13.4.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/components/ai/message-bubble.tsx",
      "content": "import { AnimatePresence, MotionConfig, motion } from 'motion/react'\nimport * as React from 'react'\n\nimport { cn } from 'cn'\n\ntype MessageStatus = 'failed' | 'read' | 'sending' | 'sent'\n\ninterface MessageBubbleProps extends React.ComponentProps<'div'> {\n  from?: 'assistant' | 'system' | 'user'\n  onRetry?: () => void\n  status?: MessageStatus\n  streaming?: boolean\n  time?: string\n  typing?: boolean\n}\n\nconst ease = [0.23, 1, 0.32, 1] as const\n\nconst statusCopy: Record<MessageStatus, string> = {\n  failed: 'Not sent',\n  read: 'Read',\n  sending: 'Sending',\n  sent: 'Sent',\n}\n\nfunction TypingDots() {\n  return (\n    <output aria-label=\"Typing\" className=\"flex h-6 items-center gap-1\">\n      {[0, 1, 2].map((dot) => (\n        <span\n          className=\"message-bubble-typing size-1.5 rounded-full bg-current\"\n          key={dot}\n          style={{ animationDelay: `${dot * 0.15}s` }}\n        />\n      ))}\n    </output>\n  )\n}\n\nfunction StatusMark({ status }: { status: MessageStatus }) {\n  if (status === 'sending')\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-3 animate-spin motion-reduce:animate-none\"\n        fill=\"none\"\n        viewBox=\"0 0 16 16\"\n      >\n        <circle cx=\"8\" cy=\"8\" opacity=\"0.25\" r=\"6\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n        <path\n          d=\"M14 8a6 6 0 0 0-6-6\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </svg>\n    )\n  if (status === 'failed')\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-3\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"1.5\"\n        viewBox=\"0 0 16 16\"\n      >\n        <circle cx=\"8\" cy=\"8\" r=\"6\" />\n        <path d=\"M8 5v3.5M8 11h.01\" />\n      </svg>\n    )\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-3 w-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 20 12\"\n    >\n      <motion.path\n        animate={{ pathLength: 1 }}\n        d=\"m1.5 6.5 3 3 6-7\"\n        initial={{ pathLength: 0 }}\n        transition={{ duration: 0.25, ease }}\n      />\n      {status === 'read' && (\n        <motion.path\n          animate={{ pathLength: 1 }}\n          d=\"m8.5 9.5 6-7\"\n          initial={{ pathLength: 0 }}\n          transition={{ delay: 0.1, duration: 0.25, ease }}\n        />\n      )}\n    </svg>\n  )\n}\n\nfunction MessageBubble({\n  children,\n  className,\n  from = 'assistant',\n  onRetry,\n  status,\n  streaming = false,\n  time,\n  typing = false,\n  ...props\n}: MessageBubbleProps) {\n  const isUser = from === 'user'\n  const words = typeof children === 'string' && streaming ? children.split(/(\\s+)/) : null\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div\n        className={cn(\n          !time && !status\n            ? 'contents'\n            : cn(\n                'flex w-full flex-col',\n                isUser ? 'items-end' : from === 'system' ? 'items-center' : 'items-start',\n              ),\n        )}\n      >\n        <div\n          aria-busy={typing || streaming}\n          data-from={from}\n          data-slot=\"message-bubble\"\n          className={cn(\n            'w-fit max-w-[85%] rounded-[16px] px-3.5 py-2.5 text-sm leading-6 transition-[opacity,translate,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:opacity-0',\n            from !== 'system' &&\n              'starting:translate-y-1 starting:scale-[0.97] motion-reduce:starting:translate-y-0 motion-reduce:starting:scale-100',\n            isUser &&\n              'origin-bottom-right bg-primary bg-linear-to-b from-[color-mix(in_oklch,var(--primary),var(--primary-foreground)_30%)] to-primary text-primary-foreground dark:bg-none',\n            from === 'assistant' && 'origin-bottom-left bg-muted text-foreground',\n            from === 'system' &&\n              'mx-auto max-w-full bg-transparent px-0 py-1 text-center text-xs text-muted-foreground',\n            status === 'failed' && 'opacity-70',\n            className,\n          )}\n          {...props}\n        >\n          {typing ? (\n            <TypingDots />\n          ) : words ? (\n            words.map((word, index) => (\n              <motion.span\n                animate={{ opacity: 1, filter: 'blur(0px)' }}\n                initial={{ opacity: 0, filter: 'blur(2px)' }}\n                key={index}\n                transition={{ delay: index * 0.035, duration: 0.25, ease }}\n              >\n                {word}\n              </motion.span>\n            ))\n          ) : (\n            children\n          )}\n        </div>\n        <AnimatePresence initial={false}>\n          {(time || status) && !typing && (\n            <motion.p\n              animate={{ opacity: 1, y: 0 }}\n              aria-live=\"polite\"\n              className=\"mt-1 flex items-center gap-1.5 px-1 text-xs leading-5 text-muted-foreground\"\n              initial={{ opacity: 0, y: -2 }}\n              transition={{ duration: 0.2, ease }}\n            >\n              {time && <time className=\"tabular-nums\">{time}</time>}\n              {time && status && <span aria-hidden=\"true\">·</span>}\n              {status && (\n                <span className=\"flex items-center gap-1\">\n                  <StatusMark status={status} />\n                  {statusCopy[status]}\n                </span>\n              )}\n              {status === 'failed' && onRetry && (\n                <button\n                  className=\"ml-1 rounded-sm underline underline-offset-2 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40\"\n                  onClick={onRetry}\n                  type=\"button\"\n                >\n                  Retry\n                </button>\n              )}\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n    </MotionConfig>\n  )\n}\n\nexport { MessageBubble }\nexport type { MessageBubbleProps, MessageStatus }\n",
      "type": "registry:component",
      "target": "@components/ai/message-bubble.tsx"
    }
  ],
  "css": {
    ".message-bubble-typing": {
      "animation": "message-bubble-typing 1.2s ease-in-out infinite"
    },
    "@keyframes message-bubble-typing": {
      "0%, 60%, 100%": {
        "transform": "translateY(0)",
        "opacity": "0.4"
      },
      "30%": {
        "transform": "translateY(-3px)",
        "opacity": "1"
      }
    },
    "@media (prefers-reduced-motion: reduce)": {
      ".message-bubble-typing": {
        "animation": "none"
      }
    }
  },
  "type": "registry:component"
}