{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message",
  "type": "registry:ui",
  "title": "Message",
  "description": "Chat message (user/assistant) with avatar, hover actions, ‹ 2/3 › branches, error/streaming states and an autoscrolling MessageList thread.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/button.json",
    "https://kuro.abstergo.fyi/r/icon-button.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/message/index.ts",
      "type": "registry:ui",
      "target": "components/kura/message/index.ts",
      "content": "export {\n  Message,\n  MessageAction,\n  type MessageActionProps,\n  MessageActions,\n  type MessageActionsProps,\n  MessageAvatar,\n  type MessageAvatarProps,\n  MessageBranch,\n  type MessageBranchProps,\n  MessageCopyButton,\n  type MessageCopyButtonProps,\n  MessageFeedback,\n  type MessageFeedbackProps,\n  type MessageProps,\n} from \"./message\";\nexport { MessageList, type MessageListProps } from \"./message-list\";\n"
    },
    {
      "path": "registry/kura/message/message-list.tsx",
      "type": "registry:ui",
      "target": "components/kura/message/message-list.tsx",
      "content": "\"use client\";\n\nimport { ArrowDown } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MessageListProps extends React.ComponentProps<\"div\"> {\n  /** Follow new content while the reader is at the bottom; stop as soon as they scroll up. Default true. */\n  stickToBottom?: boolean;\n  /** Distance in px from the bottom that still counts as “at the bottom”. Default 64. */\n  threshold?: number;\n  /** Label of the pill that appears when the reader has scrolled away. Default “Jump to latest”. */\n  jumpLabel?: string;\n  /** Classes for the inner content column (gap, max width, padding). */\n  contentClassName?: string;\n}\n\n/**\n * Scrollable chat thread (`role=\"log\"`). Sticks to the bottom while content streams in, lets the\n * reader scroll up without being yanked back, and offers a “Jump to latest” pill until they\n * return. Give it a height (`h-[480px]`, `flex-1`, …).\n */\nexport function MessageList({\n  stickToBottom = true,\n  threshold = 64,\n  jumpLabel = \"Jump to latest\",\n  contentClassName,\n  className,\n  children,\n  ...props\n}: MessageListProps) {\n  const viewport = React.useRef<HTMLDivElement>(null);\n  const content = React.useRef<HTMLDivElement>(null);\n  const stuck = React.useRef(true);\n  const lastTop = React.useRef(0);\n  const [atBottom, setAtBottom] = React.useState(true);\n  const reduced = useReducedMotion();\n\n  const distance = React.useCallback(() => {\n    const el = viewport.current;\n    return el ? el.scrollHeight - el.scrollTop - el.clientHeight : 0;\n  }, []);\n\n  const pin = React.useCallback(() => {\n    const el = viewport.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, []);\n\n  // Start at the bottom, and follow content growth (new messages, streaming tokens) while stuck.\n  React.useLayoutEffect(() => {\n    if (stickToBottom) pin();\n    const el = content.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      if (stickToBottom && stuck.current) pin();\n      setAtBottom(distance() <= threshold);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [stickToBottom, threshold, pin, distance]);\n\n  const onScroll = () => {\n    const el = viewport.current;\n    if (!el) return;\n    const bottom = distance() <= threshold;\n    if (bottom) stuck.current = true;\n    else if (el.scrollTop < lastTop.current - 1) stuck.current = false;\n    lastTop.current = el.scrollTop;\n    setAtBottom(bottom);\n  };\n\n  const jump = () => {\n    stuck.current = true;\n    viewport.current?.scrollTo({\n      top: viewport.current.scrollHeight,\n      behavior: reduced ? \"auto\" : \"smooth\",\n    });\n  };\n\n  const showJump = !atBottom;\n\n  return (\n    <div\n      data-slot=\"message-list\"\n      data-at-bottom={atBottom}\n      className={cn(\"relative flex min-h-0 flex-col overflow-hidden\", className)}\n      {...props}\n    >\n      <div\n        ref={viewport}\n        role=\"log\"\n        aria-label=\"Conversation\"\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: the log must be keyboard-scrollable\n        tabIndex={0}\n        onScroll={onScroll}\n        onWheel={(e) => {\n          // Scrolling up with a wheel or trackpad releases the stick immediately, even mid-stream.\n          if (e.deltaY < 0) stuck.current = false;\n        }}\n        data-slot=\"message-list-viewport\"\n        className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none [scrollbar-gutter:stable] [scrollbar-width:thin] focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\"\n      >\n        <div\n          ref={content}\n          data-slot=\"message-list-content\"\n          className={cn(\"flex flex-col gap-5 px-4 py-6\", contentClassName)}\n        >\n          {children}\n        </div>\n      </div>\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n        <button\n          type=\"button\"\n          data-slot=\"message-list-jump\"\n          data-state={showJump ? \"visible\" : \"hidden\"}\n          inert={!showJump || undefined}\n          aria-hidden={!showJump || undefined}\n          onClick={jump}\n          className={cn(\n            \"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-kura-overlay pr-3.5 pl-3 font-kura-sans font-medium text-kura-fg text-xs shadow-kura-md outline-none backdrop-blur-xl\",\n            \"transition-[opacity,transform,scale] duration-(--kura-duration-base) ease-kura-out focus-visible:shadow-kura-focus active:scale-[0.97]\",\n            showJump\n              ? \"pointer-events-auto translate-y-0 opacity-100\"\n              : \"translate-y-2 scale-95 opacity-0 duration-(--kura-duration-fast)\",\n            \"motion-reduce:translate-y-0 motion-reduce:scale-100 motion-reduce:active:scale-100\",\n          )}\n        >\n          <ArrowDown aria-hidden className=\"size-3.5 text-kura-fg-muted\" />\n          {jumpLabel}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/kura/message/message.tsx",
      "type": "registry:ui",
      "target": "components/kura/message/message.tsx",
      "content": "\"use client\";\n\nimport {\n  AlertCircle,\n  Check,\n  ChevronLeft,\n  ChevronRight,\n  Copy,\n  RotateCcw,\n  ThumbsDown,\n  ThumbsUp,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/kura/button\";\nimport { IconButton, type IconButtonProps } from \"@/components/kura/icon-button\";\n\n// ------------------------------------------------------------------------------------------------\n// Message\n// ------------------------------------------------------------------------------------------------\n\nexport interface MessageProps extends React.ComponentProps<\"div\"> {\n  /** Who wrote it. User messages render as a right-aligned bubble; assistant messages as open text. */\n  from: \"user\" | \"assistant\";\n  /** Display name shown in the header, e.g. \"Kura\" or \"You\". */\n  name?: React.ReactNode;\n  /** Avatar element, usually `<MessageAvatar />`. */\n  avatar?: React.ReactNode;\n  /** When it was sent. Dates/numbers render as a localized time (“10:42 AM”) with the full date as a tooltip. */\n  timestamp?: Date | number | string;\n  /** `streaming` sets aria-busy and keeps actions invisible (but laid out); `error` shows `error` and a Retry button. Default \"idle\". */\n  status?: \"idle\" | \"streaming\" | \"error\";\n  /** Error text shown when `status=\"error\"`. Default “Something went wrong.” */\n  error?: React.ReactNode;\n  /** Shows a Retry button in the error row. */\n  onRetry?: () => void;\n  /** Action bar under the content, usually `<MessageActions>`. */\n  actions?: React.ReactNode;\n}\n\n/**\n * One chat turn. Content goes in `children` — plain text, StreamingText, rendered markdown or\n * CodeBlocks; block children are spaced with a gap, not margins. Hover actions, error and\n * streaming states are built in.\n */\nexport function Message({\n  from,\n  name,\n  avatar,\n  timestamp,\n  status = \"idle\",\n  error,\n  onRetry,\n  actions,\n  className,\n  children,\n  ...props\n}: MessageProps) {\n  const user = from === \"user\";\n  return (\n    <div\n      data-slot=\"message\"\n      data-from={from}\n      data-status={status}\n      aria-busy={status === \"streaming\" || undefined}\n      className={cn(\n        \"group/message flex w-full gap-3 font-kura-sans\",\n        user && \"flex-row-reverse\",\n        className,\n      )}\n      {...props}\n    >\n      {avatar && <div className=\"shrink-0\">{avatar}</div>}\n      <div\n        className={cn(\"flex min-w-0 flex-1 flex-col gap-1.5\", user ? \"items-end\" : \"items-start\")}\n      >\n        {(name != null || timestamp != null) && (\n          <div\n            data-slot=\"message-header\"\n            className={cn(\n              \"flex h-7 items-center gap-2 text-[13px] leading-none\",\n              user && \"flex-row-reverse\",\n            )}\n          >\n            {name != null && <span className=\"font-medium text-kura-fg\">{name}</span>}\n            {timestamp != null && <MessageTime value={timestamp} />}\n          </div>\n        )}\n        <div\n          data-slot=\"message-body\"\n          className={cn(\n            \"flex min-w-0 max-w-full flex-col gap-3 text-[15px] text-kura-fg leading-relaxed\",\n            // Markdown-friendly defaults for rendered HTML. Links and lists skip anything that is (or\n            // sits inside) a nested Kura component, so e.g. a Sources strip keeps its own styling.\n            \"[&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:underline [&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:decoration-kura-border-strong [&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:underline-offset-[3px] [&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*):hover]:decoration-current\",\n            \"[&_strong]:font-semibold [&_ol:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:list-decimal [&_ol:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:pl-5 [&_ul:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:list-disc [&_ul:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:pl-5 [&_li:not([data-slot],[data-slot=message-body]_[data-slot]_*)+li]:mt-1 [&_li:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:pl-0.5 [&_li::marker]:text-kura-fg-subtle\",\n            \"[&_:not(pre)>code:not([data-slot])]:rounded-kura-xs [&_:not(pre)>code:not([data-slot])]:bg-kura-active [&_:not(pre)>code:not([data-slot])]:px-[0.3em] [&_:not(pre)>code:not([data-slot])]:py-[0.1em] [&_:not(pre)>code:not([data-slot])]:font-kura-mono [&_:not(pre)>code:not([data-slot])]:text-[0.86em]\",\n            \"[&_blockquote]:border-kura-border-strong [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:text-kura-fg-muted\",\n            \"[&_h1]:font-semibold [&_h1]:text-xl [&_h2]:font-semibold [&_h2]:text-lg [&_h3]:font-semibold [&_h3]:text-base\",\n            user\n              ? \"w-fit max-w-[min(80%,36rem)] whitespace-pre-wrap break-words rounded-kura-2xl rounded-br-kura-sm bg-kura-gray-3 px-4 py-2.5\"\n              : \"w-full\",\n            status === \"error\" && \"text-kura-fg-muted\",\n          )}\n        >\n          {children}\n        </div>\n        {status === \"error\" && (\n          <div\n            data-slot=\"message-error\"\n            role=\"alert\"\n            className=\"flex animate-kura-fade-in items-center gap-2 text-[13px] text-kura-danger-text\"\n          >\n            <AlertCircle aria-hidden className=\"size-3.5 shrink-0\" />\n            <span>{error ?? \"Something went wrong.\"}</span>\n            {onRetry && (\n              <Button variant=\"ghost\" size=\"xs\" onClick={onRetry} className=\"text-kura-fg-muted\">\n                <RotateCcw /> Retry\n              </Button>\n            )}\n          </div>\n        )}\n        {actions != null && (\n          <div\n            className={cn(\"flex\", user ? \"justify-end\" : \"-ml-1.5 justify-start\")}\n            // Keep the row laid out while streaming so actions appearing never shift the thread.\n            data-slot=\"message-actions-row\"\n            inert={status === \"streaming\" || undefined}\n          >\n            <div className={cn(status === \"streaming\" && \"invisible\")}>{actions}</div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction MessageTime({ value }: { value: Date | number | string }) {\n  if (typeof value === \"string\") {\n    return <span className=\"text-kura-fg-subtle text-xs tabular-nums\">{value}</span>;\n  }\n  const date = new Date(value);\n  return (\n    <time\n      dateTime={date.toISOString()}\n      title={date.toLocaleString()}\n      suppressHydrationWarning\n      className=\"text-kura-fg-subtle text-xs tabular-nums\"\n    >\n      {date.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" })}\n    </time>\n  );\n}\n\n// ------------------------------------------------------------------------------------------------\n// Avatar\n// ------------------------------------------------------------------------------------------------\n\nexport interface MessageAvatarProps extends React.ComponentProps<\"span\"> {\n  /** Image URL. Falls back to `fallback` (or initials of `name`) if missing or broken. */\n  src?: string;\n  /** Used for the image alt text and the initials fallback. */\n  name: string;\n  /** Custom fallback, e.g. a logo or lucide icon. */\n  fallback?: React.ReactNode;\n}\n\n/** 28px round avatar with an image, an icon fallback or initials. */\nexport function MessageAvatar({ src, name, fallback, className, ...props }: MessageAvatarProps) {\n  const [broken, setBroken] = React.useState(false);\n  const initials = name\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n  return (\n    <span\n      data-slot=\"message-avatar\"\n      className={cn(\n        \"relative flex size-7 shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-kura-surface-raised font-medium text-[11px] text-kura-fg-muted shadow-kura-xs [&_svg]:size-3.5\",\n        className,\n      )}\n      {...props}\n    >\n      {src && !broken ? (\n        <img\n          src={src}\n          alt={name}\n          className=\"size-full object-cover\"\n          onError={() => setBroken(true)}\n        />\n      ) : (\n        (fallback ?? (\n          <>\n            <span aria-hidden>{initials}</span>\n            <span className=\"sr-only\">{name}</span>\n          </>\n        ))\n      )}\n    </span>\n  );\n}\n\n// ------------------------------------------------------------------------------------------------\n// Actions\n// ------------------------------------------------------------------------------------------------\n\nexport interface MessageActionsProps extends React.ComponentProps<\"div\"> {\n  /** `hover` reveals the bar on hover/focus (always on touch screens); `always` keeps it visible. Default \"hover\". */\n  visibility?: \"hover\" | \"always\";\n}\n\n/** The action bar. Fades in on hover or keyboard focus; it is always laid out, so it never shifts the thread. */\nexport function MessageActions({ visibility = \"hover\", className, ...props }: MessageActionsProps) {\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a fieldset would add form semantics to a toolbar\n    <div\n      role=\"group\"\n      aria-label=\"Message actions\"\n      data-slot=\"message-actions\"\n      data-visibility={visibility}\n      className={cn(\n        \"flex items-center gap-0.5 text-kura-fg-subtle\",\n        visibility === \"hover\" &&\n          \"opacity-0 transition-opacity duration-(--kura-duration-fast) ease-kura-out focus-within:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport interface MessageActionProps extends Omit<IconButtonProps, \"size\"> {}\n\n/** A small labelled icon button for the action bar (regenerate, edit, share…). */\nexport function MessageAction({ className, ...props }: MessageActionProps) {\n  return (\n    <IconButton\n      data-slot=\"message-action\"\n      size=\"sm\"\n      className={cn(\"text-kura-fg-subtle\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface MessageCopyButtonProps extends Omit<MessageActionProps, \"label\" | \"icon\"> {\n  /** The text to copy (usually the message's raw markdown). */\n  text: string;\n  /** Called after a successful copy. */\n  onCopied?: () => void;\n}\n\n/** Copy action that swaps to a check (cross-fade + 2px blur) for 1.6s after copying. */\nexport function MessageCopyButton({ text, onCopied, onClick, ...props }: MessageCopyButtonProps) {\n  const [copied, setCopied] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n  const swap =\n    \"col-start-1 row-start-1 size-3.5 transition-[opacity,scale,filter] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\";\n  const hidden = \"scale-75 opacity-0 blur-[2px] motion-reduce:scale-100 motion-reduce:blur-none\";\n  return (\n    <>\n      <MessageAction\n        label={copied ? \"Copied\" : \"Copy\"}\n        data-copied={copied}\n        onClick={async (event) => {\n          onClick?.(event);\n          try {\n            await navigator.clipboard.writeText(text);\n            setCopied(true);\n            onCopied?.();\n            clearTimeout(timer.current);\n            timer.current = setTimeout(() => setCopied(false), 1600);\n          } catch {\n            // Clipboard unavailable (insecure context): do nothing.\n          }\n        }}\n        icon={\n          <span className=\"grid place-items-center\">\n            <Copy className={cn(swap, copied && hidden)} />\n            <Check className={cn(swap, \"text-kura-success-text\", !copied && hidden)} />\n          </span>\n        }\n        {...props}\n      />\n      <span className=\"sr-only\" role=\"status\">\n        {copied ? \"Copied to clipboard\" : \"\"}\n      </span>\n    </>\n  );\n}\n\nexport interface MessageFeedbackProps\n  extends Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Current rating (controlled). `null` = no rating. */\n  value?: \"up\" | \"down\" | null;\n  /** Initial rating when uncontrolled. Default null. */\n  defaultValue?: \"up\" | \"down\" | null;\n  /** Called with the new rating; pressing the active thumb again clears it to null. */\n  onValueChange?: (value: \"up\" | \"down\" | null) => void;\n}\n\n/** Thumbs up / down toggle pair (aria-pressed). Pressing the active one again clears it. */\nexport function MessageFeedback({\n  value,\n  defaultValue = null,\n  onValueChange,\n  className,\n  ...props\n}: MessageFeedbackProps) {\n  const [rating, setRating] = useControllableState<\"up\" | \"down\" | null>({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  const thumb = (kind: \"up\" | \"down\") => {\n    const active = rating === kind;\n    const Icon = kind === \"up\" ? ThumbsUp : ThumbsDown;\n    return (\n      <MessageAction\n        label={kind === \"up\" ? \"Good response\" : \"Bad response\"}\n        aria-pressed={active}\n        data-active={active || undefined}\n        className={cn(active && \"text-kura-fg\")}\n        onClick={() => setRating(active ? null : kind)}\n        icon={\n          <Icon\n            className={cn(\n              \"transition-[fill,scale] duration-(--kura-duration-fast) ease-kura-spring\",\n              active && \"scale-110 fill-current motion-reduce:scale-100\",\n            )}\n          />\n        }\n      />\n    );\n  };\n  return (\n    <div\n      data-slot=\"message-feedback\"\n      data-value={rating ?? \"none\"}\n      className={cn(\"flex items-center gap-0.5\", className)}\n      {...props}\n    >\n      {thumb(\"up\")}\n      {thumb(\"down\")}\n    </div>\n  );\n}\n\nexport interface MessageBranchProps extends React.ComponentProps<\"div\"> {\n  /** 1-based index of the visible version. */\n  current: number;\n  /** Number of versions (regenerations or edits). Renders nothing when ≤ 1. */\n  total: number;\n  /** Called with the new 1-based index. */\n  onCurrentChange?: (current: number) => void;\n}\n\n/** Version switcher “‹ 2/3 ›” for regenerated or edited messages. */\nexport function MessageBranch({\n  current,\n  total,\n  onCurrentChange,\n  className,\n  ...props\n}: MessageBranchProps) {\n  if (total <= 1) return null;\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a fieldset would add form semantics to a toolbar\n    <div\n      role=\"group\"\n      aria-label=\"Message versions\"\n      data-slot=\"message-branch\"\n      className={cn(\"flex items-center text-kura-fg-subtle\", className)}\n      {...props}\n    >\n      <MessageAction\n        label=\"Previous version\"\n        icon={<ChevronLeft />}\n        disabled={current <= 1}\n        onClick={() => onCurrentChange?.(current - 1)}\n      />\n      <span\n        aria-live=\"polite\"\n        className=\"min-w-[3ch] select-none text-center font-kura-sans text-xs tabular-nums\"\n      >\n        <span className=\"sr-only\">Version </span>\n        {current}\n        <span aria-hidden>/</span>\n        <span className=\"sr-only\"> of </span>\n        {total}\n      </span>\n      <MessageAction\n        label=\"Next version\"\n        icon={<ChevronRight />}\n        disabled={current >= total}\n        onClick={() => onCurrentChange?.(current + 1)}\n      />\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "conversation",
    "chat",
    "message",
    "conversation",
    "thread"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/message · Markdown for agents: https://kuro.abstergo.fyi/components/message.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "Render the turns of a chat with an LLM: user bubbles on the right, assistant answers as open text on the left.",
        "Add per-message actions: copy, regenerate, edit, thumbs up/down and navigation between regenerated versions.",
        "Show a failed generation with an inline error and a Retry button.",
        "Wrap a whole conversation in MessageList for stick-to-bottom autoscroll with a “Jump to latest” pill."
      ],
      "whenNotToUse": [
        "Only the streaming answer text, without chrome — use streaming-text.",
        "A model's hidden chain of thought — put a reasoning block inside the assistant Message.",
        "A tool invocation or an agent plan — use tool-call or agent-timeline inside the assistant Message.",
        "The input box at the bottom of a chat — use composer."
      ]
    }
  }
}
