{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message",
  "title": "Message",
  "description": "A chat message: the user's in a sunken bubble on the right, the assistant's flush on the left. Consecutive bubbles from one author merge, their touching corners tightening as the next one lands. Renders AI SDK UIMessage text and file parts.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-chat-core.json",
    "https://eased.dev/r/attachment.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/message/message.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport {\n  fromFilePart,\n  isFilePart,\n  isTextPart,\n  messageText,\n  type ChatMessage,\n  type ChatRole,\n  type GroupPosition,\n} from \"../_primitives/chat-core/chat-core\";\nimport { AttachmentChip } from \"../attachment/attachment-chip\";\nimport { enter, exitFast, reducedFade, smooth, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\n\nexport type MessageProps = {\n  /** An AI SDK `UIMessage` (or Eased `ChatMessage`). Its text and file parts are rendered; pass `children` to render parts yourself. */\n  message?: ChatMessage;\n  /** Defaults to `message.role`. User messages sit in a bubble on the right; assistant messages are flush (rule 95). */\n  role?: ChatRole;\n  /**\n   * Where it sits in a run from one author (`groupMessages` gives you this). Bubble corners\n   * that touch a neighbour tighten, and morph as the next message lands. Default `single`.\n   */\n  position?: GroupPosition;\n  /** Replaces the rendered text, e.g. with a streaming Response. */\n  children?: ReactNode;\n  /** An action row under the message: Copy, Retry, thumbs. */\n  actions?: ReactNode;\n  /** `hover` (default): shown on hover and focus with a fine pointer, always on touch. It keeps its space either way. */\n  actionsVisible?: \"hover\" | \"always\";\n  /** Rise in once on mount: pass true for messages that arrive after the list has rendered. */\n  animateIn?: boolean;\n  className?: string;\n};\n\nconst BIG = \"var(--eased-radius-panel)\";\nconst SMALL = \"var(--eased-radius-control-xs)\";\n\n/** The bubble's end-side corners by group position: tight where it touches a neighbour. */\nconst CORNERS: Record<GroupPosition, [top: string, bottom: string]> = {\n  single: [BIG, BIG],\n  first: [BIG, SMALL],\n  middle: [SMALL, SMALL],\n  last: [SMALL, BIG],\n};\n\n/**\n * One chat message. The user's sits in a sunken bubble on the right, the assistant's flush\n * on the left. Consecutive bubbles from one author merge: the corners where they meet\n * tighten from 16px to 6px, transitioning as the next one lands, so the run reflows instead\n * of snapping. Files show as thumbnails above the bubble.\n */\nexport function Message({\n  message,\n  role = message?.role ?? \"assistant\",\n  position = \"single\",\n  children,\n  actions,\n  actionsVisible = \"hover\",\n  animateIn = false,\n  className = \"\",\n}: MessageProps) {\n  const reduced = useSafeReducedMotion();\n  const user = role === \"user\";\n  const files = message?.parts.filter(isFilePart) ?? [];\n  const text = message ? message.parts.filter(isTextPart).map((p) => p.text).join(\"\") : \"\";\n  const body = children ?? (text ? <p className=\"whitespace-pre-wrap break-words text-pretty\">{text}</p> : null);\n  const [top, bottom] = CORNERS[position];\n\n  return (\n    <motion.div\n      data-role={role}\n      data-position={position}\n      className={`group flex min-w-0 flex-col gap-1.5 ${user ? \"items-end\" : \"items-start\"} ${className}`}\n      initial={animateIn ? (reduced ? { opacity: 0 } : { opacity: 0, y: 8 }) : false}\n      animate={{ opacity: 1, y: 0 }}\n      transition={reduced ? reducedFade : { ...smooth, opacity: enter }}\n    >\n      {files.length > 0 && (\n        <ul aria-label=\"Attachments\" className={`flex max-w-[85%] flex-wrap gap-2 ${user ? \"justify-end\" : \"\"}`}>\n          {files.map((p, i) => (\n            <li key={`${p.url}-${i}`} className=\"flex min-w-0\">\n              <AttachmentChip attachment={fromFilePart(p, `${message!.id}-${i}`)} />\n            </li>\n          ))}\n        </ul>\n      )}\n      {body &&\n        (user ? (\n          <div\n            className=\"max-w-[85%] min-w-0 bg-surface-sunken px-3.5 py-2 text-ui text-text transition-[border-radius] duration-(--eased-duration-standard) ease-standard reduced:transition-none\"\n            style={\n              {\n                borderStartStartRadius: BIG,\n                borderEndStartRadius: BIG,\n                borderStartEndRadius: top,\n                borderEndEndRadius: bottom,\n              } as CSSProperties\n            }\n          >\n            {body}\n          </div>\n        ) : (\n          <div className=\"w-full min-w-0 text-ui text-text\">{body}</div>\n        ))}\n      {actions && (\n        <div\n          className={`flex items-center gap-0.5 ${user ? \"-mr-2\" : \"-ml-2\"} ${\n            actionsVisible === \"hover\"\n              ? \"transition-opacity duration-150 ease-standard pointer-fine:opacity-0 pointer-fine:group-hover-fine:opacity-100 pointer-fine:group-focus-within:opacity-100\"\n              : \"\"\n          }`}\n        >\n          {actions}\n        </div>\n      )}\n    </motion.div>\n  );\n}\n\n/* ---------- Actions ----------------------------------------------------------- */\n\nexport type MessageActionProps = {\n  label: string;\n  onClick?: () => void;\n  children: ReactNode;\n  /** Pressed state for toggles such as thumbs up. */\n  pressed?: boolean;\n};\n\n/** A 28px ghost icon button for the action row. */\nexport function MessageAction({ label, onClick, children, pressed }: MessageActionProps) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-pressed={pressed}\n      onClick={onClick}\n      className={`inline-flex size-7 cursor-pointer items-center justify-center rounded-control-xs text-text-muted hover-fine:bg-surface-hover hover-fine:text-text aria-pressed:text-text [&_svg]:size-4 ${pressIcon}`}\n    >\n      {children}\n    </button>\n  );\n}\n\n/** Copy, morphing to a check for 1.6s once the text is on the clipboard. */\nexport function CopyAction({ message, text }: { message?: ChatMessage; text?: string }) {\n  const reduced = useSafeReducedMotion();\n  const [copied, setCopied] = useState(false);\n  const timer = useRef<number | undefined>(undefined);\n  useEffect(() => () => clearTimeout(timer.current), []);\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(text ?? (message ? messageText(message) : \"\"));\n    } catch {\n      return;\n    }\n    setCopied(true);\n    clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setCopied(false), 1600);\n  };\n  return (\n    <MessageAction label={copied ? \"Copied\" : \"Copy\"} onClick={copy}>\n      <span className=\"relative inline-flex size-4\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.svg\n            key={copied ? \"check\" : \"copy\"}\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"size-4\"\n            aria-hidden\n            initial={reduced ? { opacity: 0 } : swapIn}\n            animate={reduced ? { opacity: 1 } : swapShown}\n            exit={reduced ? { opacity: 0, transition: reducedFade } : { ...swapOut, transition: { ...swap, opacity: exitFast, filter: exitFast } }}\n            transition={reduced ? reducedFade : swap}\n          >\n            {copied ? (\n              <path d=\"M3.5 8.5l3 3 6-7\" />\n            ) : (\n              <>\n                <rect x=\"5.25\" y=\"5.25\" width=\"8\" height=\"8\" rx=\"1.75\" />\n                <path d=\"M10.75 5.25v-1a1.5 1.5 0 0 0-1.5-1.5h-4.5a2 2 0 0 0-2 2v4.5a1.5 1.5 0 0 0 1.5 1.5h1\" />\n              </>\n            )}\n          </motion.svg>\n        </AnimatePresence>\n      </span>\n      <span role=\"status\" className=\"sr-only\">\n        {copied ? \"Copied to clipboard\" : \"\"}\n      </span>\n    </MessageAction>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/message/message.tsx"
    }
  ],
  "docs": "Import Message, MessageAction and CopyAction from components/eased/message/message. Group with groupMessages from chat-core.",
  "type": "registry:ui"
}