{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-scroller",
  "title": "Message Scroller",
  "description": "A compact conversation minimap that navigates user turns while keeping the full thread visible.",
  "dependencies": [
    "cn@^0.3.0"
  ],
  "registryDependencies": [
    "https://pastaui.com/r/message-bubble.json",
    "tooltip"
  ],
  "files": [
    {
      "path": "src/components/ai/message-scroller.tsx",
      "content": "import * as React from 'react'\nimport { useEffect, useRef, useState } from 'react'\n\nimport { MessageBubble } from '@/components/ai/message-bubble'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { cn } from 'cn'\n\nconst HORIZONTAL_WAVE = [2.8, 2.15, 1.55, 1.2]\nconst VERTICAL_WAVE = [1.4, 1.22, 1.1, 1.04]\n\ninterface MessageScrollerMessage {\n  content: string\n  from?: 'assistant' | 'system' | 'user'\n  id: string\n}\n\ninterface MessageScrollerProps extends Omit<React.ComponentProps<'section'>, 'children'> {\n  activeId?: string\n  messages: MessageScrollerMessage[]\n  onMessageSelect?: (message: MessageScrollerMessage) => void\n}\n\nfunction getWaveScale(index: number, hoveredIndex: number | null, wave: number[]) {\n  if (hoveredIndex === null) return 1\n  return wave[Math.abs(index - hoveredIndex)] ?? 1\n}\n\nfunction MessageScroller({\n  activeId,\n  className,\n  messages,\n  onMessageSelect,\n  ...props\n}: MessageScrollerProps) {\n  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)\n  const [selectedId, setSelectedId] = useState(\n    activeId ?? messages.find((message) => (message.from ?? 'user') === 'user')?.id,\n  )\n  const [visibleIds, setVisibleIds] = useState<string[]>([])\n  const messageElements = useRef(new Map<string, HTMLDivElement>())\n  const viewportRef = useRef<HTMLDivElement>(null)\n\n  const currentMessageId = activeId ?? selectedId\n  const userMessages = messages.filter((message) => (message.from ?? 'user') === 'user')\n\n  useEffect(() => {\n    const viewport = viewportRef.current\n    if (!viewport) return\n    let frame = 0\n\n    function updatePosition() {\n      if (!viewport) return\n      const bounds = viewport.getBoundingClientRect()\n      const center = bounds.top + viewport.clientHeight / 2\n      const visible: string[] = []\n      let nearestId: string | undefined\n      let nearestDistance = Infinity\n\n      for (const message of messages) {\n        if ((message.from ?? 'user') !== 'user') continue\n        const element = messageElements.current.get(message.id)\n        if (!element) continue\n        const rect = element.getBoundingClientRect()\n        if (rect.bottom > bounds.top && rect.top < bounds.top + viewport.clientHeight) {\n          visible.push(message.id)\n        }\n        const distance = Math.abs(rect.top + rect.height / 2 - center)\n        if (distance < nearestDistance) {\n          nearestDistance = distance\n          nearestId = message.id\n        }\n      }\n\n      setSelectedId(nearestId)\n      setVisibleIds((previous) =>\n        previous.length === visible.length && previous.every((id, index) => id === visible[index])\n          ? previous\n          : visible,\n      )\n    }\n\n    function scheduleUpdate() {\n      cancelAnimationFrame(frame)\n      frame = requestAnimationFrame(updatePosition)\n    }\n\n    const observer = new ResizeObserver(scheduleUpdate)\n    observer.observe(viewport)\n    for (const element of messageElements.current.values()) observer.observe(element)\n    viewport.addEventListener('scroll', scheduleUpdate, { passive: true })\n    updatePosition()\n    return () => {\n      cancelAnimationFrame(frame)\n      observer.disconnect()\n      viewport.removeEventListener('scroll', scheduleUpdate)\n    }\n  }, [messages])\n\n  function selectMessage(message: MessageScrollerMessage) {\n    const viewport = viewportRef.current\n    const target = messageElements.current.get(message.id)\n\n    if (viewport && target) {\n      const viewportBounds = viewport.getBoundingClientRect()\n      const targetBounds = target.getBoundingClientRect()\n      const top =\n        viewport.scrollTop +\n        targetBounds.top -\n        viewportBounds.top -\n        viewport.clientHeight / 2 +\n        targetBounds.height / 2\n\n      viewport.scrollTo({\n        behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',\n        top,\n      })\n    }\n\n    onMessageSelect?.(message)\n  }\n\n  return (\n    <TooltipProvider>\n      <section\n        className={cn('relative flex h-60 w-full overflow-hidden', className)}\n        data-slot=\"message-scroller\"\n        {...props}\n      >\n        <nav\n          aria-label=\"User message navigation\"\n          className=\"absolute inset-y-0 left-3 z-10 flex w-12 flex-col justify-center gap-0.5 py-2\"\n          onPointerLeave={() => setHoveredIndex(null)}\n        >\n          {userMessages.map((message, index) => {\n            const isActive = message.id === currentMessageId\n            const isVisible = visibleIds.includes(message.id)\n            const isHovered = index === hoveredIndex\n            const scaleX = getWaveScale(index, hoveredIndex, HORIZONTAL_WAVE)\n            const scaleY = getWaveScale(index, hoveredIndex, VERTICAL_WAVE)\n\n            return (\n              <Tooltip key={message.id} open={isHovered}>\n                <TooltipTrigger\n                  aria-current={isActive ? 'true' : undefined}\n                  aria-label={`Jump to user message: ${message.content}`}\n                  className=\"flex h-2.5 w-full items-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n                  onBlur={() => setHoveredIndex(null)}\n                  onClick={() => selectMessage(message)}\n                  onFocus={() => setHoveredIndex(index)}\n                  onPointerEnter={() => setHoveredIndex(index)}\n                  type=\"button\"\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className={cn(\n                      'block h-0.5 w-3 origin-left rounded-full bg-muted-foreground/35 opacity-80 transition-[transform,background-color,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] will-change-transform motion-reduce:transition-[background-color,opacity]',\n                      (isVisible || isActive || isHovered) && 'bg-foreground opacity-100',\n                    )}\n                    style={{ transform: `scaleX(${scaleX}) scaleY(${scaleY})` }}\n                  />\n                </TooltipTrigger>\n                <TooltipContent\n                  className=\"w-64 max-w-[calc(100vw-5rem)] bg-transparent p-0 text-foreground shadow-none [&>:last-child]:hidden\"\n                  side=\"right\"\n                  sideOffset={12}\n                >\n                  <MessageBubble\n                    className=\"max-w-full rounded-[12px] px-3 py-2 text-xs leading-5\"\n                    from={message.from ?? 'user'}\n                  >\n                    {message.content}\n                  </MessageBubble>\n                </TooltipContent>\n              </Tooltip>\n            )\n          })}\n        </nav>\n\n        <div\n          ref={viewportRef}\n          className=\"h-full min-w-0 flex-1 [scrollbar-width:none] overflow-y-auto overscroll-contain [mask-image:linear-gradient(to_bottom,transparent,#000_3rem,#000_calc(100%-3rem),transparent)] py-20 pr-4 pl-18 [&::-webkit-scrollbar]:hidden\"\n          data-slot=\"message-scroller-viewport\"\n        >\n          <div className=\"flex flex-col gap-3\">\n            {messages.map((message) => (\n              <div\n                key={message.id}\n                ref={(element) => {\n                  if (element) messageElements.current.set(message.id, element)\n                  else messageElements.current.delete(message.id)\n                }}\n                data-message-id={message.id}\n              >\n                <MessageBubble\n                  className=\"rounded-[12px] px-3 py-2 text-xs leading-5\"\n                  from={message.from ?? 'user'}\n                >\n                  {message.content}\n                </MessageBubble>\n              </div>\n            ))}\n          </div>\n        </div>\n      </section>\n    </TooltipProvider>\n  )\n}\n\nexport { MessageScroller }\nexport type { MessageScrollerMessage, MessageScrollerProps }\n",
      "type": "registry:component",
      "target": "@components/ai/message-scroller.tsx"
    }
  ],
  "type": "registry:component"
}