{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "conversation-thread",
  "title": "Conversation Thread",
  "description": "A streaming AI chat surface where messages spring in, tokens reveal with a blinking caret, hover actions fade in, and the view auto-sticks to the latest message.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/conversation-thread/conversation-thread.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport * as React from \"react\";\n\nexport type ConversationVariant = \"bubbles\" | \"document\" | \"compact\";\nexport type MessageRole = \"user\" | \"assistant\" | \"system\";\n\ntype ThreadContextValue = { variant: ConversationVariant };\nconst ThreadContext = React.createContext<ThreadContextValue>({\n  variant: \"bubbles\",\n});\n\nexport type ConversationThreadProps = React.HTMLAttributes<HTMLDivElement> & {\n  variant?: ConversationVariant;\n  /** Auto-stick to the newest message unless the user scrolls up. */\n  autoScroll?: boolean;\n};\n\nconst THREAD_BASE =\n  \"relative flex h-full flex-col gap-4 overflow-y-auto px-4 py-4\";\n\nconst ConversationThread = React.forwardRef<\n  HTMLDivElement,\n  ConversationThreadProps\n>(\n  (\n    { variant = \"bubbles\", autoScroll = true, className, children, ...props },\n    forwardedRef,\n  ) => {\n    const ref = React.useRef<HTMLDivElement>(null);\n    const contentRef = React.useRef<HTMLDivElement>(null);\n    React.useImperativeHandle(\n      forwardedRef,\n      () => ref.current as HTMLDivElement,\n    );\n    const [pinned, setPinned] = React.useState(true);\n    // Ref mirror so ResizeObserver / effects never read a stale `pinned`.\n    const pinnedRef = React.useRef(true);\n\n    const setPinnedBoth = React.useCallback((next: boolean) => {\n      pinnedRef.current = next;\n      setPinned(next);\n    }, []);\n\n    const scrollToBottom = React.useCallback((behavior: ScrollBehavior) => {\n      const el = ref.current;\n      if (!el) return;\n      // `instant` keeps streaming growth glued without fighting layout;\n      // `smooth` is reserved for the intentional Jump-to-latest click.\n      if (typeof el.scrollTo === \"function\") {\n        el.scrollTo({ top: el.scrollHeight, behavior });\n      } else {\n        el.scrollTop = el.scrollHeight;\n      }\n    }, []);\n\n    // Stick to the bottom when a new message mounts.\n    const childCount = React.Children.count(children);\n    // biome-ignore lint/correctness/useExhaustiveDependencies: re-pin when a message is added\n    React.useEffect(() => {\n      if (autoScroll && pinnedRef.current) scrollToBottom(\"instant\");\n    }, [childCount, autoScroll, scrollToBottom]);\n\n    // Stick through *content* growth too (StreamingText ticks, wrapping) —\n    // childCount alone misses that. Observe the inner stack, not the scroll\n    // port: ResizeObserver on an overflow container does not fire when only\n    // scrollHeight grows.\n    React.useEffect(() => {\n      if (!autoScroll) return;\n      const content = contentRef.current;\n      if (!content || typeof ResizeObserver === \"undefined\") return;\n      const ro = new ResizeObserver(() => {\n        if (pinnedRef.current) scrollToBottom(\"instant\");\n      });\n      ro.observe(content);\n      return () => ro.disconnect();\n    }, [autoScroll, scrollToBottom]);\n\n    return (\n      <ThreadContext.Provider value={{ variant }}>\n        <div\n          ref={ref}\n          data-slot=\"conversation-thread\"\n          data-variant={variant}\n          className={`${THREAD_BASE} ${className ?? \"\"}`}\n          onScroll={(e) => {\n            const el = e.currentTarget;\n            const atBottom =\n              el.scrollHeight - el.scrollTop - el.clientHeight < 48;\n            setPinnedBoth(atBottom);\n          }}\n          {...props}\n        >\n          <div ref={contentRef} className=\"flex flex-col gap-4\">\n            {children}\n          </div>\n          <AnimatePresence>\n            {!pinned ? (\n              <motion.button\n                type=\"button\"\n                initial={{ opacity: 0, y: 8, scale: 0.9 }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                exit={{ opacity: 0, y: 8, scale: 0.9 }}\n                transition={{\n                  type: \"spring\",\n                  stiffness: 320,\n                  damping: 32,\n                  mass: 0.9,\n                }}\n                onClick={() => {\n                  setPinnedBoth(true);\n                  scrollToBottom(\"smooth\");\n                }}\n                className=\"sticky bottom-2 left-1/2 z-raised inline-flex -translate-x-1/2 items-center gap-1.5 self-center rounded-full border border-border bg-popover px-3 py-1.5 text-xs font-medium text-foreground shadow-lg\"\n              >\n                Jump to latest\n                <ArrowDownIcon className=\"size-3.5\" />\n              </motion.button>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </ThreadContext.Provider>\n    );\n  },\n);\nConversationThread.displayName = \"ConversationThread\";\n\nexport type MessageAction = {\n  label: string;\n  icon: React.ReactNode;\n  onClick?: () => void;\n};\n\nexport type ConversationMessageProps = React.HTMLAttributes<HTMLDivElement> & {\n  role: MessageRole;\n  name?: string;\n  avatar?: React.ReactNode;\n  timestamp?: string;\n  /** Hover actions (copy, regenerate, …) revealed on hover. */\n  actions?: MessageAction[];\n  /** Shows a blinking caret at the end while tokens stream in. */\n  streaming?: boolean;\n};\n\nconst BUBBLE_BY_ROLE: Record<MessageRole, string> = {\n  user: \"bg-primary text-primary-foreground\",\n  assistant: \"bg-muted text-foreground\",\n  system: \"bg-transparent text-muted-foreground italic\",\n};\n\nconst ConversationMessage = React.forwardRef<\n  HTMLDivElement,\n  ConversationMessageProps\n>(\n  (\n    {\n      role,\n      name,\n      avatar,\n      timestamp,\n      actions,\n      streaming = false,\n      className,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const { variant } = React.useContext(ThreadContext);\n    const reduce = useReducedMotion();\n    const isUser = role === \"user\";\n    const isDocument = variant === \"document\";\n    const isCompact = variant === \"compact\";\n\n    // No `layout` prop: streaming text grows the bubble every tick, and a\n    // layout animation on every message turns that growth into visible jumps\n    // (siblings re-measure and tween). Opacity-only enter is enough.\n    return (\n      <motion.div\n        ref={ref}\n        initial={reduce ? false : { opacity: 0, y: 8 }}\n        animate={{ opacity: 1, y: 0 }}\n        transition={{ type: \"spring\", stiffness: 320, damping: 32, mass: 0.9 }}\n        data-slot=\"conversation-message\"\n        data-role={role}\n        className={`group/msg flex gap-3 ${isUser && !isDocument ? \"flex-row-reverse\" : \"\"} ${isCompact ? \"gap-2\" : \"\"} ${className ?? \"\"}`}\n        {...(props as React.ComponentProps<typeof motion.div>)}\n      >\n        {avatar ? (\n          <div className=\"mt-0.5 size-7 shrink-0 overflow-hidden rounded-full bg-muted text-xs\">\n            {avatar}\n          </div>\n        ) : null}\n        <div\n          className={`flex min-w-0 flex-col gap-1 ${isUser && !isDocument ? \"items-end\" : \"items-start\"} ${isDocument ? \"w-full\" : \"max-w-[80%]\"}`}\n        >\n          {(name || timestamp) && !isCompact ? (\n            <div className=\"flex items-center gap-2 px-1 text-xs text-muted-foreground\">\n              {name ? (\n                <span className=\"font-medium text-foreground\">{name}</span>\n              ) : null}\n              {timestamp ? (\n                <span className=\"tabular-nums\">{timestamp}</span>\n              ) : null}\n            </div>\n          ) : null}\n          <div\n            className={\n              isDocument\n                ? \"w-full text-sm leading-7 text-foreground\"\n                : `rounded-2xl px-3.5 py-2 text-sm leading-6 shadow-2xs ${BUBBLE_BY_ROLE[role]} ${isUser ? \"rounded-br-md\" : \"rounded-bl-md\"}`\n            }\n          >\n            <span className=\"[overflow-wrap:anywhere] whitespace-pre-wrap\">\n              {children}\n              {streaming ? (\n                <span className=\"ml-0.5 inline-block h-[1.05em] w-[2px] -translate-y-px animate-pulse bg-current align-middle motion-reduce:animate-none\" />\n              ) : null}\n            </span>\n          </div>\n          {actions && actions.length > 0 ? (\n            <div\n              className={`flex gap-0.5 px-1 opacity-0 transition-opacity group-hover/msg:opacity-100 ${isUser && !isDocument ? \"flex-row-reverse\" : \"\"}`}\n            >\n              {actions.map((action) => (\n                <button\n                  key={action.label}\n                  type=\"button\"\n                  aria-label={action.label}\n                  onClick={action.onClick}\n                  className=\"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n                >\n                  {action.icon}\n                </button>\n              ))}\n            </div>\n          ) : null}\n        </div>\n      </motion.div>\n    );\n  },\n);\nConversationMessage.displayName = \"ConversationMessage\";\n\nexport type StreamingTextProps = {\n  /** Full text to reveal token-by-token. */\n  text: string;\n  /** Characters revealed per tick. */\n  chunk?: number;\n  /** Milliseconds between ticks. */\n  speed?: number;\n  /** Fired once the full text is revealed. */\n  onDone?: () => void;\n};\n\n/**\n * Reveals `text` progressively. Honors reduced-motion by showing it instantly.\n */\nfunction StreamingText({\n  text,\n  chunk = 2,\n  speed = 24,\n  onDone,\n}: StreamingTextProps) {\n  const reduce = useReducedMotion();\n  const [count, setCount] = React.useState(reduce ? text.length : 0);\n\n  React.useEffect(() => {\n    if (reduce) {\n      setCount(text.length);\n      onDone?.();\n      return;\n    }\n    setCount(0);\n    let current = 0;\n    const id = setInterval(() => {\n      current = Math.min(current + chunk, text.length);\n      setCount(current);\n      if (current >= text.length) {\n        clearInterval(id);\n        onDone?.();\n      }\n    }, speed);\n    return () => clearInterval(id);\n  }, [text, chunk, speed, reduce, onDone]);\n\n  return <>{text.slice(0, count)}</>;\n}\n\ntype IconProps = { className?: string };\nfunction ArrowDownIcon({ className }: IconProps) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      aria-hidden=\"true\"\n    >\n      <path d=\"M12 5v14M19 12l-7 7-7-7\" />\n    </svg>\n  );\n}\n\nexport { ConversationMessage, ConversationThread, StreamingText };\n",
      "type": "registry:ui",
      "target": "components/godui/conversation-thread.tsx"
    }
  ],
  "type": "registry:ui"
}
