{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-message",
  "title": "AI Message",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ui.ballmac.com/r/button.json",
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/ai-message.tsx",
      "content": "// Ballmac UI: AI Message. https://ui.ballmac.com/components/ai-message\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Copy } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { cn } from \"@/lib/utils\"\nimport { useLocale, useMessages } from \"@/lib/ballmac/i18n\"\n\ntype MessageRole = \"user\" | \"assistant\" | \"system\"\n\nconst MessageContext = React.createContext<{ role: MessageRole }>({ role: \"assistant\" })\n\ntype MessageProps = Omit<React.ComponentProps<\"div\">, \"role\"> & {\n  /** Who sent the message. User messages sit right in a bubble; assistant messages run full width; system messages are centered notes. */\n  role?: MessageRole\n}\n\nfunction Message({ role = \"assistant\", className, ...props }: MessageProps) {\n  const value = React.useMemo(() => ({ role }), [role])\n  return (\n    <MessageContext.Provider value={value}>\n      <div\n        data-slot=\"message\"\n        data-role={role}\n        className={cn(\n          \"group/message grid w-full gap-y-1.5 has-[>[data-slot=message-avatar]]:gap-x-3\",\n          role === \"user\" && \"grid-cols-[minmax(0,1fr)_auto]\",\n          role === \"assistant\" && \"grid-cols-[auto_minmax(0,1fr)]\",\n          role === \"system\" && \"grid-cols-1 justify-items-center\",\n          className\n        )}\n        {...props}\n      />\n    </MessageContext.Provider>\n  )\n}\n\n/** Column placement shared by every part except the avatar. */\nfunction useBodyPlacement() {\n  const { role } = React.useContext(MessageContext)\n  if (role === \"user\") return \"col-start-1 justify-self-end\"\n  if (role === \"assistant\") return \"col-start-2 justify-self-start\"\n  return \"col-start-1\"\n}\n\ntype MessageAvatarProps = React.ComponentProps<\"div\">\n\nfunction MessageAvatar({ className, ...props }: MessageAvatarProps) {\n  const { role } = React.useContext(MessageContext)\n  return (\n    <div\n      data-slot=\"message-avatar\"\n      className={cn(\n        \"row-start-1 flex size-8 shrink-0 items-center justify-center self-start overflow-hidden rounded-full border bg-muted font-mono text-[11px] font-medium text-muted-foreground [&_img]:size-full [&_img]:object-cover [&_svg]:size-4\",\n        role === \"user\" ? \"col-start-2\" : \"col-start-1\",\n        role === \"system\" && \"hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\ntype MessageContentProps = React.ComponentProps<\"div\">\n\nfunction MessageContent({ className, ...props }: MessageContentProps) {\n  const { role } = React.useContext(MessageContext)\n  const placement = useBodyPlacement()\n  return (\n    <div\n      data-slot=\"message-content\"\n      className={cn(\n        placement,\n        \"min-w-0 text-sm break-words\",\n        role === \"user\" &&\n          \"max-w-[85%] rounded-xl rounded-ee-sm bg-muted px-3.5 py-2 leading-6 whitespace-pre-wrap text-foreground\",\n        role === \"assistant\" &&\n          \"w-full leading-7 text-foreground [&_code]:font-mono [&_code]:text-[0.9em] [&_pre]:overflow-x-auto [&>*+*]:mt-3 [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:list-disc [&_ul]:ps-5\",\n        role === \"system\" &&\n          \"rounded-md border border-dashed px-3 py-1.5 text-center font-mono text-xs text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\ntype MessageActionsProps = React.ComponentProps<\"div\"> & {\n  /** \"hover\" reveals the row on hover or keyboard focus on devices with a mouse (always visible on touch). \"always\" keeps it visible. */\n  visibility?: \"hover\" | \"always\"\n}\n\nfunction MessageActions({ visibility = \"hover\", className, ...props }: MessageActionsProps) {\n  const msg = useMessages()\n  const { role } = React.useContext(MessageContext)\n  const placement = useBodyPlacement()\n  return (\n    <div\n      data-slot=\"message-actions\"\n      role=\"toolbar\"\n      aria-label={msg(\"ai-message.messageActions\", \"Message actions\")}\n      className={cn(\n        placement,\n        \"flex items-center gap-0.5\",\n        role === \"assistant\" && \"-ms-1.5\",\n        visibility === \"hover\" &&\n          \"transition-opacity duration-150 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/message:opacity-100 [@media(hover:hover)]:group-hover/message:opacity-100\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\ntype MessageActionProps = Omit<React.ComponentProps<typeof Button>, \"variant\" | \"size\"> & {\n  /** Accessible name for the icon-only button, also shown as a native tooltip. */\n  label: string\n}\n\nfunction MessageAction({ label, className, ...props }: MessageActionProps) {\n  return (\n    <Button\n      data-slot=\"message-action\"\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon-sm\"\n      aria-label={label}\n      title={label}\n      className={cn(\n        \"size-7 text-muted-foreground hover:text-foreground aria-pressed:text-foreground focus-visible:ring-offset-0 [&_svg:not([class*='size-'])]:size-3.5\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\ntype MessageCopyActionProps = Omit<MessageActionProps, \"label\" | \"onClick\"> & {\n  /** Text written to the clipboard, usually the message's raw markdown. */\n  value: string\n  /** Accessible name before copying. */\n  label?: string\n  /** Called after the text was copied. */\n  onCopied?: () => void\n}\n\nfunction MessageCopyAction({ value, label, onCopied, ...props }: MessageCopyActionProps) {\n  const msg = useMessages()\n  label ??= msg(\"ai-message.label\", \"Copy message\")\n  const [copied, setCopied] = React.useState(false)\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n  React.useEffect(() => () => clearTimeout(timer.current), [])\n\n  async function copy() {\n    try {\n      await navigator.clipboard.writeText(value)\n    } catch {\n      return\n    }\n    setCopied(true)\n    onCopied?.()\n    clearTimeout(timer.current)\n    timer.current = setTimeout(() => setCopied(false), 1800)\n  }\n\n  return (\n    <>\n      <MessageAction label={copied ? \"Copied\" : label} onClick={copy} {...props}>\n        {copied ? <Check /> : <Copy />}\n      </MessageAction>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {copied ? \"Copied to clipboard\" : \"\"}\n      </span>\n    </>\n  )\n}\n\ntype MessageTimestampProps = Omit<React.ComponentProps<\"time\">, \"children\"> & {\n  /** When the message was sent. */\n  date: Date | string | number\n  /** BCP 47 locale for formatting. */\n  locale?: string\n  /** IANA time zone. When omitted the viewer's zone is used, formatted after hydration so server and browser agree. */\n  timeZone?: string\n  /** Intl.DateTimeFormat options. */\n  format?: Intl.DateTimeFormatOptions\n  /** Preformatted text; overrides the built-in formatting. */\n  children?: React.ReactNode\n}\n\nconst subscribeNothing = () => () => {}\n\nfunction MessageTimestamp({\n  date,\n  locale,\n  timeZone,\n  format = { hour: \"numeric\", minute: \"2-digit\" },\n  className,\n  children,\n  ...props\n}: MessageTimestampProps) {\n  const defaultLocale = useLocale()\n  locale ??= defaultLocale\n  const placement = useBodyPlacement()\n  const isClient = React.useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false\n  )\n  const d = new Date(date)\n  const valid = !Number.isNaN(d.getTime())\n  const text =\n    children ??\n    (valid && (timeZone || isClient) ? new Intl.DateTimeFormat(locale, { ...format, timeZone }).format(d) : null)\n\n  return (\n    <time\n      data-slot=\"message-timestamp\"\n      dateTime={valid ? d.toISOString() : undefined}\n      className={cn(placement, \"min-h-4 font-mono text-[11px] text-muted-foreground tabular-nums\", className)}\n      {...props}\n    >\n      {text}\n    </time>\n  )\n}\n\nexport {\n  Message,\n  MessageAvatar,\n  MessageContent,\n  MessageActions,\n  MessageAction,\n  MessageCopyAction,\n  MessageTimestamp,\n  type MessageRole,\n  type MessageProps,\n  type MessageAvatarProps,\n  type MessageContentProps,\n  type MessageActionsProps,\n  type MessageActionProps,\n  type MessageCopyActionProps,\n  type MessageTimestampProps,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/ai-message.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "chat",
      "message",
      "llm",
      "assistant",
      "copy"
    ],
    "version": "1.0.0",
    "updated": "2026-09-28",
    "ai": {
      "summary": "Render one chat turn. <Message role> wraps <MessageAvatar>, <MessageContent>, <MessageActions> (with <MessageAction label> and <MessageCopyAction value>) and <MessageTimestamp date>. It takes plain children, so it works with the Vercel AI SDK, LangChain or any stream.",
      "whenToUse": [
        "Rendering each turn of an assistant or chatbot conversation",
        "Showing copy, regenerate and feedback buttons under an assistant reply",
        "A system note inside a conversation, such as 'Model switched to a smaller context window'"
      ],
      "whenNotToUse": [
        "The scrolling conversation layout itself (use ai-chat and put messages inside ChatMessages)",
        "Threaded comments or email-style lists where both sides look alike"
      ],
      "composesWith": [
        "ai-chat",
        "prompt-input",
        "streaming-text",
        "tool-call-card",
        "reasoning-disclosure",
        "code-block"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Moves through the action buttons; focusing any of them reveals the action row"
        },
        {
          "keys": "Enter / Space",
          "action": "Runs the focused action"
        }
      ],
      "customization": [
        "role: user | assistant | system (sets alignment and bubble styling through context)",
        "MessageActions visibility: hover (default; always visible on touch devices) | always",
        "MessageAction requires a label (used as aria-label and title); pass aria-pressed for thumbs up/down",
        "MessageTimestamp formats with locale (en-US default) and timeZone; without timeZone it formats after hydration in the viewer's zone, or pass preformatted children",
        "Render markdown yourself inside MessageContent; it styles lists, code and spacing lightly",
        "AI SDK: messages.map(m => <Message key={m.id} role={m.role}><MessageContent>{m.parts.map(p => p.type === 'text' ? p.text : null)}</MessageContent></Message>)"
      ]
    },
    "examples": [
      "ai-message-demo",
      "ai-message-actions"
    ],
    "url": "https://ui.ballmac.com/components/ai-message"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}