{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat",
  "title": "Chat",
  "description": "Chat / AI-assistant kit: MessageScroller (auto-follow, jump-to-latest, prepend anchoring), Message, Bubble, Attachment and Marker",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@boldkit/utils",
    "@boldkit/progress",
    "@boldkit/motion"
  ],
  "files": [
    {
      "path": "registry/default/ui/message-scroller.tsx",
      "content": "import * as React from 'react'\nimport { ArrowDown } from 'lucide-react'\nimport { cn } from '@/lib/utils'\n\n/*\n * MessageScroller — the scroll container for a conversation.\n *\n * It owns scroll behaviour only (never message rendering):\n *  - auto-follow: while the reader is within `threshold` px of the bottom, new\n *    messages and streamed growth of the last one keep the log pinned there.\n *    Growth is observed with a ResizeObserver on the content, not timers.\n *  - any upward scroll by the reader stops following (even inside `threshold`)\n *    and reveals the jump-to-latest button, which counts messages appended\n *    while the reader was away. Following resumes on reaching the bottom, or\n *    on a downward move back inside `threshold`.\n *  - `onReachTop` fires on scrolling into the top `threshold`, and also when\n *    the history doesn't overflow the viewport at all (no scroll can happen) —\n *    once per first message, so a page that loads nothing can't loop.\n *  - prepend preservation: when older history is inserted above the first\n *    message, scrollTop is shifted by the scrollHeight delta so the viewport\n *    stays on the same message. `overflow-anchor: none` turns off the browser's\n *    own scroll anchoring so the two never double-apply.\n *\n * Messages are found by their `data-message-id` attribute.\n */\n\nexport interface MessageScrollerScrollOptions {\n  behavior?: ScrollBehavior\n}\n\nexport interface MessageScrollerScrollToMessageOptions\n  extends MessageScrollerScrollOptions {\n  block?: 'start' | 'center' | 'end'\n}\n\nexport interface MessageScrollerHandle {\n  /** Scroll to the newest content and resume auto-follow. */\n  scrollToBottom: (options?: MessageScrollerScrollOptions) => void\n  /** Scroll a `[data-message-id]` element into view. Returns false when not found. */\n  scrollToMessage: (id: string, options?: MessageScrollerScrollToMessageOptions) => boolean\n  /** Whether the viewport is currently within `threshold` of the bottom. */\n  isAtBottom: () => boolean\n}\n\ninterface MessageScrollerContextValue {\n  setViewport: (node: HTMLDivElement | null) => void\n  setContent: (node: HTMLDivElement | null) => void\n  handleScroll: () => void\n  jumpToLatest: () => void\n  focusViewport: () => void\n  /** +1 when a consumer-placed jump button mounts, -1 when it unmounts. */\n  registerJumpButton: (delta: number) => void\n  atBottom: boolean\n  unread: number\n  streaming: boolean\n}\n\nconst MessageScrollerContext = React.createContext<MessageScrollerContextValue | null>(null)\n\n// True only around the root's own auto-placed jump button, so it doesn't\n// count as a consumer-placed one.\nconst AutoJumpButtonContext = React.createContext(false)\n\nfunction useMessageScrollerContext(part: string) {\n  const ctx = React.useContext(MessageScrollerContext)\n  if (!ctx) throw new Error(`<${part}> must be used within <MessageScroller>`)\n  return ctx\n}\n\nfunction prefersReducedMotion() {\n  return (\n    typeof window !== 'undefined' &&\n    typeof window.matchMedia === 'function' &&\n    window.matchMedia('(prefers-reduced-motion: reduce)').matches\n  )\n}\n\nfunction scrollViewport(viewport: HTMLElement, top: number, behavior: ScrollBehavior) {\n  if (behavior === 'smooth' && !prefersReducedMotion() && typeof viewport.scrollTo === 'function') {\n    viewport.scrollTo({ top, behavior: 'smooth' })\n  } else {\n    viewport.scrollTop = top\n  }\n}\n\nfunction distanceFromBottom(el: HTMLElement) {\n  return el.scrollHeight - el.scrollTop - el.clientHeight\n}\n\nfunction messageIds(content: HTMLElement) {\n  return Array.from(content.querySelectorAll('[data-message-id]'), (node) =>\n    node.getAttribute('data-message-id')\n  )\n}\n\nexport interface MessageScrollerProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Marks the log `aria-busy` while a response is streaming. */\n  streaming?: boolean\n  /** Distance in px from the bottom (and top) that still counts as \"at\" it. */\n  threshold?: number\n  /** Called once each time the reader scrolls into the top `threshold` — load older history here. */\n  onReachTop?: () => void\n}\n\nconst MessageScroller = React.forwardRef<MessageScrollerHandle, MessageScrollerProps>(\n  ({ className, children, streaming = false, threshold = 48, onReachTop, ...props }, ref) => {\n    // Elements live in refs (they're mutated — scrollTop). `attached` bumps\n    // only when a *new* element mounts, so the observer effect re-runs then —\n    // and an unstable consumer ref (null → same node each render) can't loop.\n    const viewportRef = React.useRef<HTMLDivElement | null>(null)\n    const contentRef = React.useRef<HTMLDivElement | null>(null)\n    const seenRef = React.useRef(new WeakSet<HTMLDivElement>())\n    const [attached, setAttached] = React.useState(0)\n    const attach = React.useCallback(\n      (target: React.MutableRefObject<HTMLDivElement | null>, node: HTMLDivElement | null) => {\n        target.current = node\n        if (node && !seenRef.current.has(node)) {\n          seenRef.current.add(node)\n          setAttached((n) => n + 1)\n        }\n      },\n      []\n    )\n    const setViewport = React.useCallback(\n      (node: HTMLDivElement | null) => attach(viewportRef, node),\n      [attach]\n    )\n    const setContent = React.useCallback(\n      (node: HTMLDivElement | null) => attach(contentRef, node),\n      [attach]\n    )\n    // `atBottom` mirrors \"following\": false is what shows the jump button.\n    const [atBottom, setAtBottom] = React.useState(true)\n    const [unread, setUnread] = React.useState(0)\n    const [customJumpButtons, setCustomJumpButtons] = React.useState(0)\n\n    const followingRef = React.useRef(true)\n    // Last measured geometry + boundary ids. scrollHeight is only recorded by\n    // `sync` (the ResizeObserver), never by the scroll handler, so a scroll\n    // event landing between a prepend and its observation can't hide the delta.\n    const snapshotRef = React.useRef({\n      scrollTop: 0,\n      scrollHeight: 0,\n      firstId: null as string | null,\n      lastId: null as string | null,\n      nearTop: true,\n      // First message id when onReachTop last fired for non-overflowing content.\n      reachTopFiredFor: undefined as string | null | undefined,\n    })\n    const onReachTopRef = React.useRef(onReachTop)\n    React.useEffect(() => {\n      onReachTopRef.current = onReachTop\n    }, [onReachTop])\n\n    const sync = React.useCallback(() => {\n      const viewport = viewportRef.current\n      const content = contentRef.current\n      if (!viewport || !content) return\n      const snap = snapshotRef.current\n      const ids = messageIds(content)\n      const firstId = ids[0] ?? null\n      const lastId = ids[ids.length - 1] ?? null\n\n      // Messages appended after the previously-last one (for the unread badge).\n      let appended = 0\n      if (snap.lastId !== null && lastId !== snap.lastId) {\n        const index = ids.lastIndexOf(snap.lastId)\n        if (index !== -1) appended = ids.length - 1 - index\n      }\n      // History inserted above the previously-first message.\n      const prepended =\n        snap.firstId !== null && firstId !== snap.firstId && ids.includes(snap.firstId)\n\n      if (followingRef.current) {\n        viewport.scrollTop = viewport.scrollHeight\n      } else if (prepended) {\n        viewport.scrollTop = snap.scrollTop + (viewport.scrollHeight - snap.scrollHeight)\n      }\n\n      if (distanceFromBottom(viewport) <= 1) followingRef.current = true\n      if (followingRef.current) setUnread(0)\n      else if (appended > 0) setUnread((n) => n + appended)\n      setAtBottom(followingRef.current)\n\n      snap.scrollTop = viewport.scrollTop\n      snap.scrollHeight = viewport.scrollHeight\n      snap.firstId = firstId\n      snap.lastId = lastId\n\n      // Nothing to scroll means no top-zone transition will ever happen, so ask\n      // for more history directly — again only once the first message changed.\n      const fits = viewport.scrollHeight - viewport.clientHeight <= threshold\n      if (fits && snap.reachTopFiredFor !== firstId) {\n        snap.reachTopFiredFor = firstId\n        onReachTopRef.current?.()\n      }\n    }, [threshold])\n\n    React.useEffect(() => {\n      const viewport = viewportRef.current\n      const content = contentRef.current\n      if (!viewport || !content) return\n      sync()\n      if (typeof ResizeObserver === 'undefined') return\n      const observer = new ResizeObserver(() => sync())\n      observer.observe(content)\n      observer.observe(viewport)\n      return () => observer.disconnect()\n    }, [attached, sync])\n\n    const handleScroll = React.useCallback(() => {\n      const viewport = viewportRef.current\n      if (!viewport) return\n      const snap = snapshotRef.current\n      const top = viewport.scrollTop\n      const distance = distanceFromBottom(viewport)\n      // Our own scrolls never read as upward: sync/prepend anchoring/\n      // scrollToBottom only move down and record snap.scrollTop afterwards, and\n      // a clamp from shrinking content lands exactly at the bottom.\n      if (distance <= 1) followingRef.current = true\n      else if (top < snap.scrollTop - 1) followingRef.current = false\n      else if (distance <= threshold) followingRef.current = true\n      if (followingRef.current) setUnread(0)\n      setAtBottom(followingRef.current)\n      snap.scrollTop = top\n\n      const nearTop = top <= threshold\n      if (nearTop && !snap.nearTop) onReachTopRef.current?.()\n      snap.nearTop = nearTop\n    }, [threshold])\n\n    const scrollToBottom = React.useCallback(\n      ({ behavior = 'smooth' }: MessageScrollerScrollOptions = {}) => {\n        const viewport = viewportRef.current\n        if (!viewport) return\n        followingRef.current = true\n        setUnread(0)\n        setAtBottom(true)\n        scrollViewport(viewport, viewport.scrollHeight, behavior)\n        snapshotRef.current.scrollTop = viewport.scrollTop\n      },\n      []\n    )\n\n    const scrollToMessage = React.useCallback(\n      (id: string, { behavior = 'smooth', block = 'start' }: MessageScrollerScrollToMessageOptions = {}) => {\n        const viewport = viewportRef.current\n        const content = contentRef.current\n        if (!viewport || !content) return false\n        const target = Array.from(content.querySelectorAll<HTMLElement>('[data-message-id]')).find(\n          (node) => node.getAttribute('data-message-id') === id\n        )\n        if (!target) return false\n        const rect = target.getBoundingClientRect()\n        let top = rect.top - viewport.getBoundingClientRect().top + viewport.scrollTop\n        if (block === 'center') top -= (viewport.clientHeight - rect.height) / 2\n        if (block === 'end') top -= viewport.clientHeight - rect.height\n        const maxTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight)\n        top = Math.min(maxTop, Math.max(0, top))\n        followingRef.current = maxTop - top <= threshold\n        setAtBottom(followingRef.current)\n        scrollViewport(viewport, top, behavior)\n        snapshotRef.current.scrollTop = viewport.scrollTop\n        return true\n      },\n      [threshold]\n    )\n\n    React.useImperativeHandle(\n      ref,\n      () => ({\n        scrollToBottom,\n        scrollToMessage,\n        isAtBottom: () =>\n          viewportRef.current ? distanceFromBottom(viewportRef.current) <= threshold : true,\n      }),\n      [scrollToBottom, scrollToMessage, threshold]\n    )\n\n    const focusViewport = React.useCallback(\n      () => viewportRef.current?.focus({ preventScroll: true }),\n      []\n    )\n    // Stable, so a consumer jump button's register effect runs once per mount.\n    const registerJumpButton = React.useCallback(\n      (delta: number) => setCustomJumpButtons((n) => n + delta),\n      []\n    )\n\n    const contextValue = React.useMemo<MessageScrollerContextValue>(\n      () => ({\n        setViewport,\n        setContent,\n        handleScroll,\n        jumpToLatest: () => scrollToBottom({ behavior: 'smooth' }),\n        focusViewport,\n        registerJumpButton,\n        atBottom,\n        unread,\n        streaming,\n      }),\n      [\n        setViewport,\n        setContent,\n        handleScroll,\n        scrollToBottom,\n        focusViewport,\n        registerJumpButton,\n        atBottom,\n        unread,\n        streaming,\n      ]\n    )\n\n    return (\n      <MessageScrollerContext.Provider value={contextValue}>\n        <div\n          data-slot=\"message-scroller\"\n          data-at-bottom={atBottom}\n          className={cn('relative flex min-h-0 flex-col', className)}\n          {...props}\n        >\n          {children}\n          {/* Auto-placed unless a consumer's jump button is mounted anywhere inside. */}\n          {customJumpButtons === 0 && (\n            <AutoJumpButtonContext.Provider value={true}>\n              <MessageScrollerJumpButton />\n            </AutoJumpButtonContext.Provider>\n          )}\n        </div>\n      </MessageScrollerContext.Provider>\n    )\n  }\n)\nMessageScroller.displayName = 'MessageScroller'\n\nconst MessageScrollerContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, children, onScroll, ...props }, ref) => {\n  const { setViewport, setContent, handleScroll, streaming } =\n    useMessageScrollerContext('MessageScrollerContent')\n  const viewportRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      setViewport(node)\n      if (typeof ref === 'function') ref(node)\n      else if (ref) ref.current = node\n    },\n    [setViewport, ref]\n  )\n\n  return (\n    <div\n      ref={viewportRef}\n      role=\"log\"\n      aria-live=\"polite\"\n      aria-label=\"Conversation\"\n      // A keyboard user must be able to focus the region to scroll it.\n      tabIndex={0}\n      data-slot=\"message-scroller-content\"\n      {...props}\n      aria-busy={streaming || undefined}\n      onScroll={(event) => {\n        onScroll?.(event)\n        handleScroll()\n      }}\n      className={cn(\n        'bk-scroll-fade min-h-0 flex-1 overflow-y-auto overscroll-contain [overflow-anchor:none] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring',\n        className\n      )}\n    >\n      <div\n        ref={setContent}\n        data-slot=\"message-scroller-list\"\n        className=\"flex min-h-full flex-col justify-end gap-4 p-4\"\n      >\n        {children}\n      </div>\n    </div>\n  )\n})\nMessageScrollerContent.displayName = 'MessageScrollerContent'\n\nconst MessageScrollerJumpButton = React.forwardRef<\n  HTMLButtonElement,\n  React.ButtonHTMLAttributes<HTMLButtonElement>\n>(({ className, children, onClick, ...props }, ref) => {\n  const ctx = useMessageScrollerContext('MessageScrollerJumpButton')\n  const isAuto = React.useContext(AutoJumpButtonContext)\n  const { registerJumpButton } = ctx\n  React.useEffect(() => {\n    if (isAuto) return\n    registerJumpButton(1)\n    return () => registerJumpButton(-1)\n  }, [isAuto, registerJumpButton])\n\n  if (ctx.atBottom) return null\n  const count = ctx.unread > 99 ? '99+' : String(ctx.unread)\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      data-slot=\"message-scroller-jump-button\"\n      aria-label={\n        ctx.unread > 0\n          ? `Jump to latest message, ${ctx.unread} new`\n          : 'Jump to latest message'\n      }\n      {...props}\n      onClick={(event) => {\n        onClick?.(event)\n        if (event.defaultPrevented) return\n        // The button unmounts once following resumes; hand keyboard focus to\n        // the log instead of letting it fall to <body>.\n        if (document.activeElement === event.currentTarget) ctx.focusViewport()\n        ctx.jumpToLatest()\n      }}\n      className={cn(\n        'absolute bottom-4 right-4 z-10 inline-flex h-11 w-11 items-center justify-center border-3 border-foreground bg-primary text-primary-foreground shadow-[4px_4px_0px_hsl(var(--shadow-color))] transition duration-200 animate-in fade-in-0 slide-in-from-bottom-2 hover:translate-x-[4px] hover:translate-y-[4px] hover:shadow-none focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring focus-visible:ring-offset-2',\n        className\n      )}\n    >\n      {children ?? <ArrowDown className=\"h-5 w-5\" strokeWidth={3} aria-hidden=\"true\" />}\n      {ctx.unread > 0 && (\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute -right-3 -top-3 flex h-6 min-w-6 items-center justify-center border-2 border-foreground bg-accent px-1 text-xs font-bold text-accent-foreground\"\n        >\n          {count}\n        </span>\n      )}\n    </button>\n  )\n})\nMessageScrollerJumpButton.displayName = 'MessageScrollerJumpButton'\n\nexport { MessageScroller, MessageScrollerContent, MessageScrollerJumpButton }\n",
      "type": "registry:ui",
      "target": "components/ui/message-scroller.tsx"
    },
    {
      "path": "registry/default/ui/message.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\n\n/*\n * Message — one conversation row. Layout is a two-column grid (avatar | body);\n * the parts read `from` / `grouped` off the row's data attributes through\n * `group/message` variants, so they need no context and compose in any order.\n *\n *   user      → avatar on the right, body right-aligned\n *   assistant → avatar on the left, body left-aligned\n *   system    → centred, no avatar\n *\n * `grouped` marks a follow-up from the same author: the header is dropped and\n * the avatar keeps its column width but collapses, so bubbles stay aligned.\n */\n\nexport type MessageFrom = 'user' | 'assistant' | 'system'\n\n// Body parts sit in the column opposite the avatar.\nconst bodyColumn =\n  'group-data-[from=user]/message:col-start-1 group-data-[from=assistant]/message:col-start-2'\n\nexport interface MessageProps extends React.HTMLAttributes<HTMLDivElement> {\n  from?: MessageFrom\n  /** Consecutive message from the same author: hide header, collapse avatar. */\n  grouped?: boolean\n}\n\nconst Message = React.forwardRef<HTMLDivElement, MessageProps>(\n  ({ className, from = 'assistant', grouped = false, ...props }, ref) => (\n    <div\n      ref={ref}\n      data-slot=\"message\"\n      data-from={from}\n      data-grouped={grouped}\n      className={cn(\n        'group/message grid w-full gap-x-3 gap-y-1.5',\n        from === 'user' && 'grid-cols-[minmax(0,1fr)_auto] justify-items-end',\n        from === 'assistant' && 'grid-cols-[auto_minmax(0,1fr)] justify-items-start',\n        from === 'system' && 'grid-cols-1 justify-items-center',\n        grouped && '-mt-2',\n        className\n      )}\n      {...props}\n    />\n  )\n)\nMessage.displayName = 'Message'\n\nconst MessageAvatar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      ref={ref}\n      data-slot=\"message-avatar\"\n      className={cn(\n        'row-span-3 row-start-1 shrink-0 self-start',\n        'group-data-[from=user]/message:col-start-2 group-data-[from=assistant]/message:col-start-1 group-data-[from=system]/message:hidden',\n        // Grouped: keep the column width (bubbles stay aligned) but take no height.\n        'group-data-[grouped=true]/message:invisible group-data-[grouped=true]/message:h-0 group-data-[grouped=true]/message:overflow-hidden',\n        className\n      )}\n      {...props}\n    />\n  )\n)\nMessageAvatar.displayName = 'MessageAvatar'\n\nexport interface MessageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Author name, rendered bold and uppercase. */\n  name?: React.ReactNode\n  /** Display timestamp, e.g. \"10:42\". */\n  timestamp?: React.ReactNode\n  /** Machine-readable value for the `<time>` element, e.g. an ISO string. */\n  dateTime?: string\n}\n\nconst MessageHeader = React.forwardRef<HTMLDivElement, MessageHeaderProps>(\n  ({ className, name, timestamp, dateTime, children, ...props }, ref) => (\n    <div\n      ref={ref}\n      data-slot=\"message-header\"\n      className={cn(\n        'flex items-baseline gap-2 text-xs',\n        bodyColumn,\n        'group-data-[from=user]/message:flex-row-reverse group-data-[grouped=true]/message:hidden',\n        className\n      )}\n      {...props}\n    >\n      {name != null && <span className=\"font-bold uppercase tracking-wide\">{name}</span>}\n      {timestamp != null && (\n        <time dateTime={dateTime} className=\"font-mono text-muted-foreground\">\n          {timestamp}\n        </time>\n      )}\n      {children}\n    </div>\n  )\n)\nMessageHeader.displayName = 'MessageHeader'\n\nconst MessageContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      ref={ref}\n      data-slot=\"message-content\"\n      className={cn(\n        'flex min-w-0 max-w-[min(85%,42rem)] flex-col items-start gap-2',\n        bodyColumn,\n        'group-data-[from=user]/message:items-end group-data-[from=system]/message:items-center',\n        className\n      )}\n      {...props}\n    />\n  )\n)\nMessageContent.displayName = 'MessageContent'\n\nconst MessageFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      ref={ref}\n      data-slot=\"message-footer\"\n      className={cn(\n        'flex items-center gap-1.5 pt-1 text-xs',\n        bodyColumn,\n        'group-data-[from=user]/message:flex-row-reverse',\n        className\n      )}\n      {...props}\n    />\n  )\n)\nMessageFooter.displayName = 'MessageFooter'\n\nexport { Message, MessageAvatar, MessageHeader, MessageContent, MessageFooter }\n",
      "type": "registry:ui",
      "target": "components/ui/message.tsx"
    },
    {
      "path": "registry/default/ui/bubble.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\n\n/*\n * Bubble — the hard-edged message surface. No tail: the offset shadow does the\n * pointing, falling towards the author's side (right for assistant/start,\n * left for user/end). With `align=\"auto\"` it reads the side from an enclosing\n * <Message> via `group/message`.\n */\n\nexport type BubbleVariant = 'default' | 'primary' | 'muted' | 'outline'\nexport type BubbleAlign = 'auto' | 'start' | 'end'\n\nexport interface BubbleReaction {\n  emoji: string\n  count: number\n  /** Accessible name for the emoji, e.g. \"thumbs up\". Defaults to the emoji. */\n  label?: string\n  /** The current user reacted with this emoji. */\n  active?: boolean\n}\n\nconst variantClasses: Record<BubbleVariant, string> = {\n  default: 'bg-card text-card-foreground',\n  primary: 'bg-primary text-primary-foreground',\n  muted: 'bg-muted text-foreground',\n  outline: 'bg-transparent text-foreground',\n}\n\nconst shadowStart = 'shadow-[4px_4px_0px_hsl(var(--shadow-color))]'\nconst shadowEnd = 'shadow-[-4px_4px_0px_hsl(var(--shadow-color))]'\nconst alignClasses: Record<BubbleAlign, string> = {\n  start: shadowStart,\n  end: shadowEnd,\n  auto: `${shadowStart} group-data-[from=user]/message:shadow-[-4px_4px_0px_hsl(var(--shadow-color))]`,\n}\n\nexport interface BubbleProps extends React.HTMLAttributes<HTMLDivElement> {\n  variant?: BubbleVariant\n  /** Which side the shadow falls towards. `auto` follows the enclosing Message. */\n  align?: BubbleAlign\n  /** Clamp the content to this many lines with a \"Show more\" toggle when it overflows. */\n  collapsedLines?: number\n  reactions?: BubbleReaction[]\n  /** Makes reaction chips toggle buttons. */\n  onReactionClick?: (reaction: BubbleReaction, index: number) => void\n  /** The reply hasn't started yet: shows hopping dots in place of the content. */\n  typing?: boolean\n  /** Content is still arriving: adds a blinking caret and marks the bubble busy. */\n  streaming?: boolean\n}\n\nconst Bubble = React.forwardRef<HTMLDivElement, BubbleProps>(\n  (\n    {\n      className,\n      variant = 'default',\n      align = 'auto',\n      collapsedLines,\n      reactions,\n      onReactionClick,\n      typing = false,\n      streaming = false,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const contentId = React.useId()\n    const contentRef = React.useRef<HTMLDivElement>(null)\n    const [expanded, setExpanded] = React.useState(false)\n    const [overflowing, setOverflowing] = React.useState(false)\n    const collapsible = collapsedLines != null && collapsedLines > 0\n    const clamped = collapsible && !expanded\n\n    // Only measurable while clamped; once expanded, the toggle must stay so\n    // the reader can collapse again.\n    React.useEffect(() => {\n      const el = contentRef.current\n      if (!el || !clamped) return\n      const measure = () => setOverflowing(el.scrollHeight > el.clientHeight + 1)\n      measure()\n      if (typeof ResizeObserver === 'undefined') return\n      const observer = new ResizeObserver(measure)\n      observer.observe(el)\n      return () => observer.disconnect()\n    }, [clamped, collapsedLines, children])\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"bubble\"\n        data-variant={variant}\n        aria-busy={typing || streaming || undefined}\n        className={cn(\n          'w-fit max-w-full border-3 border-foreground px-4 py-3 text-sm leading-relaxed break-words',\n          // Links and inline code inside message text.\n          '[&_a]:font-bold [&_a]:underline [&_a]:decoration-2 [&_a]:underline-offset-2 [&_a:hover]:decoration-4',\n          '[&_code]:border-2 [&_code]:border-current [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.9em]',\n          variantClasses[variant],\n          variant === 'outline' ? 'shadow-none' : alignClasses[align],\n          className\n        )}\n        {...props}\n      >\n        <div\n          ref={contentRef}\n          id={contentId}\n          data-slot=\"bubble-content\"\n          data-state={collapsible ? (expanded ? 'expanded' : 'collapsed') : undefined}\n          className={cn('space-y-2', clamped && 'overflow-hidden', streaming && !typing && 'bk-stream-caret')}\n          style={\n            clamped\n              ? { display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: collapsedLines }\n              : undefined\n          }\n        >\n          {typing ? (\n            <>\n              <span aria-hidden=\"true\" className=\"bk-typing\">\n                <span />\n                <span />\n                <span />\n              </span>\n              <span className=\"sr-only\">Typing…</span>\n            </>\n          ) : (\n            children\n          )}\n        </div>\n\n        {collapsible && (overflowing || expanded) && (\n          <button\n            type=\"button\"\n            aria-expanded={expanded}\n            aria-controls={contentId}\n            onClick={() => setExpanded((v) => !v)}\n            className=\"mt-2 text-xs font-bold uppercase tracking-wide underline decoration-2 underline-offset-4 hover:decoration-4 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring\"\n          >\n            {expanded ? 'Show less' : 'Show more'}\n          </button>\n        )}\n\n        {reactions && reactions.length > 0 && (\n          <ul aria-label=\"Reactions\" className=\"mt-3 flex flex-wrap gap-1.5\">\n            {reactions.map((reaction, index) => {\n              const chip = cn(\n                'inline-flex items-center gap-1 border-2 border-foreground px-1.5 py-0.5 text-xs font-bold',\n                reaction.active\n                  ? 'bg-accent text-accent-foreground'\n                  : 'bg-background text-foreground'\n              )\n              const label = `${reaction.label ?? reaction.emoji}: ${reaction.count}`\n              return (\n                <li key={`${reaction.emoji}-${index}`}>\n                  {onReactionClick ? (\n                    <button\n                      type=\"button\"\n                      aria-pressed={!!reaction.active}\n                      aria-label={label}\n                      onClick={() => onReactionClick(reaction, index)}\n                      className={cn(\n                        chip,\n                        'shadow-[2px_2px_0px_hsl(var(--shadow-color))] transition duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring'\n                      )}\n                    >\n                      <span aria-hidden=\"true\">{reaction.emoji}</span>\n                      <span aria-hidden=\"true\">{reaction.count}</span>\n                    </button>\n                  ) : (\n                    <span className={chip}>\n                      <span aria-hidden=\"true\">{reaction.emoji}</span>\n                      <span aria-hidden=\"true\">{reaction.count}</span>\n                      <span className=\"sr-only\">{label}</span>\n                    </span>\n                  )}\n                </li>\n              )\n            })}\n          </ul>\n        )}\n      </div>\n    )\n  }\n)\nBubble.displayName = 'Bubble'\n\nexport { Bubble }\n",
      "type": "registry:ui",
      "target": "components/ui/bubble.tsx"
    },
    {
      "path": "registry/default/ui/attachment.tsx",
      "content": "import * as React from 'react'\nimport { Download, File, FileAudio, FileCode, FileText, FileVideo, Image, X } from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport { Progress } from '@/components/ui/progress'\n\n/*\n * Attachment — a file or image card inside a message.\n *\n * When the card opens something (`href` or `onOpen`) it uses the\n * stretched-link pattern: the file name is the one real link/button and its\n * ::after covers the card, while the action buttons sit above it (z-10) as\n * siblings — never nested inside another interactive element.\n */\n\nexport type AttachmentState = 'uploading' | 'done' | 'error'\n\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return '0 Bytes'\n  const k = 1024\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']\n  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]\n}\n\nfunction getFileIcon(mimeType = '') {\n  if (mimeType.startsWith('image/')) return Image\n  if (mimeType.startsWith('video/')) return FileVideo\n  if (mimeType.startsWith('audio/')) return FileAudio\n  if (mimeType.includes('pdf') || mimeType.includes('document')) return FileText\n  if (mimeType.includes('code') || mimeType.includes('javascript') || mimeType.includes('json'))\n    return FileCode\n  return File\n}\n\nexport interface AttachmentProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** File name — also the accessible name of the open link and actions. */\n  name: string\n  /** Size in bytes. */\n  size?: number\n  /** MIME type, picks the file icon. */\n  type?: string\n  /** Image preview URL. When set, the card renders as an image card. */\n  src?: string\n  /** Alt text for the preview. The file name is shown beside it, so it defaults to \"\". */\n  alt?: string\n  /** Preview aspect ratio (width / height). */\n  aspectRatio?: number\n  /** Makes the whole card a link. */\n  href?: string\n  /** Makes the whole card a button (ignored when `href` is set). */\n  onOpen?: () => void\n  state?: AttachmentState\n  /** Upload progress 0–100. Omit while uploading for an indeterminate bar. */\n  progress?: number\n  /** Error message for `state=\"error\"`. */\n  error?: string\n  onDownload?: () => void\n  onRemove?: () => void\n}\n\nconst actionClass =\n  'relative z-10 flex h-8 w-8 shrink-0 items-center justify-center border-2 border-foreground bg-background text-foreground transition duration-200 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[2px_2px_0px_hsl(var(--shadow-color))] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring'\n\nconst Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>(\n  (\n    {\n      className,\n      name,\n      size,\n      type,\n      src,\n      alt = '',\n      aspectRatio = 4 / 3,\n      href,\n      onOpen,\n      state = 'done',\n      progress,\n      error,\n      onDownload,\n      onRemove,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const uploading = state === 'uploading'\n    const failed = state === 'error'\n    // An in-flight upload has nothing to open yet.\n    const opens = !uploading && (href != null || onOpen != null)\n    const isImage = src != null\n\n    const stretched =\n      'after:absolute after:inset-0 after:content-[\"\"] focus-visible:outline-none focus-visible:after:ring-3 focus-visible:after:ring-ring focus-visible:after:ring-offset-2'\n    const nameClass = 'block truncate text-sm font-bold'\n    const title = opens ? (\n      href != null ? (\n        <a href={href} className={cn(nameClass, stretched, 'hover:underline')}>\n          {name}\n        </a>\n      ) : (\n        <button\n          type=\"button\"\n          onClick={onOpen}\n          className={cn(nameClass, stretched, 'max-w-full text-left hover:underline')}\n        >\n          {name}\n        </button>\n      )\n    ) : (\n      <span className={nameClass}>{name}</span>\n    )\n\n    let meta: React.ReactNode = size != null ? formatBytes(size) : null\n    if (uploading) meta = progress != null ? `Uploading · ${Math.round(progress)}%` : 'Uploading…'\n    if (failed) meta = error ?? 'Upload failed'\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"attachment\"\n        data-state={state}\n        className={cn(\n          'relative flex w-72 max-w-full flex-col border-3 border-foreground bg-card text-card-foreground shadow-[4px_4px_0px_hsl(var(--shadow-color))] transition duration-200',\n          opens && 'hover:translate-x-[4px] hover:translate-y-[4px] hover:shadow-none',\n          failed && 'border-destructive shadow-[4px_4px_0px_hsl(var(--destructive))]',\n          className\n        )}\n        {...props}\n      >\n        {isImage && (\n          <div className=\"overflow-hidden border-b-3 border-inherit\" style={{ aspectRatio }}>\n            <img\n              src={src}\n              alt={alt}\n              className={cn('h-full w-full object-cover', uploading && 'opacity-60')}\n            />\n          </div>\n        )}\n\n        <div className=\"flex items-center gap-3 p-3\">\n          {!isImage && (\n            <div className=\"flex h-10 w-10 shrink-0 items-center justify-center border-3 border-foreground bg-muted\">\n              {/* A lookup of module-level icons, not a component created during render. */}\n              {React.createElement(getFileIcon(type), { className: 'h-5 w-5', 'aria-hidden': true })}\n            </div>\n          )}\n          <div className=\"min-w-0 flex-1\">\n            {title}\n            {meta != null && (\n              <p\n                className={cn(\n                  'text-xs font-bold uppercase tracking-wide',\n                  failed ? 'text-destructive' : 'text-muted-foreground'\n                )}\n              >\n                {meta}\n              </p>\n            )}\n          </div>\n          {(onDownload || onRemove || children) && (\n            <div className=\"relative z-10 flex shrink-0 items-center gap-1.5\">\n              {children}\n              {onDownload && !uploading && !failed && (\n                <button\n                  type=\"button\"\n                  aria-label={`Download ${name}`}\n                  onClick={onDownload}\n                  className={cn(actionClass, 'hover:bg-primary hover:text-primary-foreground')}\n                >\n                  <Download className=\"h-4 w-4\" strokeWidth={2.5} aria-hidden=\"true\" />\n                </button>\n              )}\n              {onRemove && (\n                <button\n                  type=\"button\"\n                  aria-label={uploading ? `Cancel upload of ${name}` : `Remove ${name}`}\n                  onClick={onRemove}\n                  className={cn(actionClass, 'hover:bg-destructive hover:text-destructive-foreground')}\n                >\n                  <X className=\"h-4 w-4\" strokeWidth={2.5} aria-hidden=\"true\" />\n                </button>\n              )}\n            </div>\n          )}\n        </div>\n\n        {uploading && (\n          <div className=\"px-3 pb-3\">\n            <Progress\n              value={progress}\n              variant={progress == null ? 'marquee' : 'smooth'}\n              aria-label={`Uploading ${name}`}\n              className=\"h-3 border-2 shadow-none\"\n            />\n          </div>\n        )}\n      </div>\n    )\n  }\n)\nAttachment.displayName = 'Attachment'\n\nexport { Attachment }\n",
      "type": "registry:ui",
      "target": "components/ui/attachment.tsx"
    },
    {
      "path": "registry/default/ui/marker.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\n\n/*\n * Marker — inline status rows inside the conversation log:\n *   note      — a system note (\"ADA JOINED THE CHAT\")\n *   separator — a labeled rule (\"TODAY\")\n *   status    — live activity (\"SEARCHING THE WEB…\"); `active` adds the\n *               `.bk-shimmer` text effect while the work is in flight.\n *\n * No live-region role of its own: inside MessageScroller the surrounding\n * role=\"log\" already announces it, and a nested live region double-announces.\n */\n\nexport type MarkerVariant = 'note' | 'separator' | 'status'\n\nexport interface MarkerProps extends React.HTMLAttributes<HTMLDivElement> {\n  variant?: MarkerVariant\n  /** Streaming / tool activity in progress — shimmers the label. */\n  active?: boolean\n  icon?: React.ReactNode\n}\n\nconst Marker = React.forwardRef<HTMLDivElement, MarkerProps>(\n  ({ className, variant = 'note', active = false, icon, children, ...props }, ref) => {\n    const label = (\n      <span data-slot=\"marker-label\" className={cn(active && 'bk-shimmer')}>\n        {children}\n      </span>\n    )\n    const iconNode = icon != null && (\n      <span aria-hidden=\"true\" className=\"flex shrink-0 items-center [&_svg]:h-4 [&_svg]:w-4\">\n        {icon}\n      </span>\n    )\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"marker\"\n        data-variant={variant}\n        data-active={active}\n        className={cn(\n          'flex w-full items-center text-xs font-bold uppercase tracking-widest',\n          variant === 'note' && 'justify-center',\n          variant === 'separator' && 'gap-3',\n          variant === 'status' && 'gap-2',\n          className\n        )}\n        {...props}\n      >\n        {variant === 'separator' ? (\n          <>\n            <span aria-hidden=\"true\" className=\"h-[3px] flex-1 bg-foreground\" />\n            <span className=\"inline-flex items-center gap-2 border-3 border-foreground bg-background px-2 py-0.5 shadow-[2px_2px_0px_hsl(var(--shadow-color))]\">\n              {iconNode}\n              {label}\n            </span>\n            <span aria-hidden=\"true\" className=\"h-[3px] flex-1 bg-foreground\" />\n          </>\n        ) : variant === 'status' ? (\n          <>\n            {iconNode && (\n              <span\n                className={cn(\n                  'flex h-7 w-7 shrink-0 items-center justify-center border-2 border-foreground',\n                  active ? 'bg-accent text-accent-foreground' : 'bg-background'\n                )}\n              >\n                {iconNode}\n              </span>\n            )}\n            {label}\n          </>\n        ) : (\n          <span className=\"inline-flex items-center gap-2 border-2 border-dashed border-foreground bg-muted px-3 py-1 text-foreground\">\n            {iconNode}\n            {label}\n          </span>\n        )}\n      </div>\n    )\n  }\n)\nMarker.displayName = 'Marker'\n\nexport { Marker }\n",
      "type": "registry:ui",
      "target": "components/ui/marker.tsx"
    }
  ],
  "type": "registry:ui"
}