{"version":3,"file":"chat-messages.cjs","sources":["../../../components/chat/chat-messages.tsx"],"sourcesContent":["'use client';\n\nimport { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area';\nimport { cx } from 'class-variance-authority';\nimport {\n  ComponentProps,\n  MouseEvent,\n  ReactNode,\n  RefObject,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState\n} from 'react';\nimport { ArrowDownIcon } from '~/icons';\nimport { ScrollAreaScrollbar } from '../scroll-area/scroll-area-scrollbar';\nimport { usePrefersReducedMotion } from '../tour/use-prefers-reduced-motion';\nimport styles from './chat.module.css';\nimport {\n  ChatMessageRegistration,\n  ChatMessagesActions,\n  ChatMessagesActionsContext,\n  ChatMessagesRegistry,\n  ChatMessagesRegistryContext,\n  ChatMessagesState,\n  ChatMessagesStateContext,\n  useChatMessagesActions,\n  useChatMessagesState\n} from './chat-context';\n\nexport interface ChatMessagesProps extends ComponentProps<'div'> {\n  /**\n   * Distance from the bottom, in pixels, within which the reader still\n   * counts as being at the live edge.\n   * @defaultValue 24\n   */\n  bottomThreshold?: number;\n  /**\n   * Gap kept between the viewport top and a message anchored by\n   * `scrollAnchor` or scrolled to with `scrollToMessage`, in pixels.\n   * @defaultValue 12\n   */\n  anchorOffset?: number;\n  /**\n   * Whether to follow new content while the reader is at the live edge.\n   * @defaultValue true\n   */\n  autoScroll?: boolean;\n  /** A ref populated with the imperative scroll commands. */\n  actionsRef?: RefObject<ChatMessagesActions | null>;\n  /**\n   * Accessible label for the message log.\n   * @defaultValue \"Conversation\"\n   */\n  'aria-label'?: string;\n}\n\ninterface PendingAnchor {\n  element: HTMLElement;\n}\n\nexport function ChatMessages({\n  className,\n  children,\n  bottomThreshold = 24,\n  anchorOffset = 12,\n  autoScroll = true,\n  actionsRef,\n  'aria-label': ariaLabel = 'Conversation',\n  ...props\n}: ChatMessagesProps) {\n  const viewportRef = useRef<HTMLDivElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n\n  const [atBottom, setAtBottomState] = useState(true);\n  const atBottomRef = useRef(true);\n  const followingRef = useRef(true);\n  const autoScrollRef = useRef(autoScroll);\n  autoScrollRef.current = autoScroll;\n  const bottomThresholdRef = useRef(bottomThreshold);\n  bottomThresholdRef.current = bottomThreshold;\n  const anchorOffsetRef = useRef(anchorOffset);\n  anchorOffsetRef.current = anchorOffset;\n\n  const [visibleMessageIds, setVisibleMessageIds] = useState<string[]>([]);\n  const [spacerHeight, setSpacerHeight] = useState(0);\n  const spacerHeightRef = useRef(0);\n  const anchorShrinkRef = useRef<{\n    rawAtAnchor: number;\n    spacerAtAnchor: number;\n  } | null>(null);\n\n  const registryMapRef = useRef(new Map<string, HTMLElement>());\n  const visibleSetRef = useRef(new Set<string>());\n  const intersectionObserverRef = useRef<IntersectionObserver | null>(null);\n\n  const mountedRef = useRef(false);\n  const pendingAnchorRef = useRef<PendingAnchor | null>(null);\n  const [anchorTick, setAnchorTick] = useState(0);\n  const prevFirstRef = useRef<{ element: Element; top: number } | null>(null);\n  const scrollingToBottomRef = useRef(false);\n\n  const reducedMotion = usePrefersReducedMotion();\n  const reducedMotionRef = useRef(reducedMotion);\n  reducedMotionRef.current = reducedMotion;\n\n  const setAtBottom = useCallback((next: boolean) => {\n    if (atBottomRef.current === next) return;\n    atBottomRef.current = next;\n    setAtBottomState(next);\n  }, []);\n\n  const isAtBottom = useCallback(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return true;\n    return (\n      viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <=\n      bottomThresholdRef.current\n    );\n  }, []);\n\n  /** Offset of an element within the viewport's scroll coordinate space. */\n  const offsetWithin = useCallback((element: Element) => {\n    const viewport = viewportRef.current;\n    if (!viewport) return 0;\n    return (\n      element.getBoundingClientRect().top -\n      viewport.getBoundingClientRect().top +\n      viewport.scrollTop\n    );\n  }, []);\n\n  const setSpacer = useCallback((next: number) => {\n    if (spacerHeightRef.current === next) return;\n    spacerHeightRef.current = next;\n    setSpacerHeight(next);\n  }, []);\n\n  const scrollViewportTo = useCallback(\n    (top: number, behavior: ScrollBehavior) => {\n      const viewport = viewportRef.current;\n      if (!viewport) return;\n      if (typeof viewport.scrollTo === 'function') {\n        viewport.scrollTo({ top, behavior });\n      } else {\n        viewport.scrollTop = top;\n      }\n    },\n    []\n  );\n\n  const scrollToBottom = useCallback(\n    (behavior: ScrollBehavior = 'smooth') => {\n      const viewport = viewportRef.current;\n      if (!viewport) return;\n      followingRef.current = true;\n      const resolved: ScrollBehavior = reducedMotionRef.current\n        ? 'auto'\n        : behavior;\n      if (resolved === 'smooth') scrollingToBottomRef.current = true;\n      scrollViewportTo(viewport.scrollHeight, resolved);\n      setAtBottom(true);\n    },\n    [scrollViewportTo, setAtBottom]\n  );\n\n  const scrollToMessage = useCallback(\n    (id: string, options?: { behavior?: ScrollBehavior }) => {\n      const viewport = viewportRef.current;\n      const element = registryMapRef.current.get(id);\n      if (!viewport || !element) return;\n      followingRef.current = false;\n      scrollViewportTo(\n        Math.max(0, offsetWithin(element) - anchorOffsetRef.current),\n        reducedMotionRef.current ? 'auto' : (options?.behavior ?? 'smooth')\n      );\n    },\n    [offsetWithin, scrollViewportTo]\n  );\n\n  const updateVisibleIds = useCallback(() => {\n    const registry = registryMapRef.current;\n    const ids = Array.from(visibleSetRef.current);\n    ids.sort((a, b) => {\n      const elementA = registry.get(a);\n      const elementB = registry.get(b);\n      if (!elementA || !elementB) return 0;\n      return elementA.compareDocumentPosition(elementB) &\n        Node.DOCUMENT_POSITION_FOLLOWING\n        ? -1\n        : 1;\n    });\n    setVisibleMessageIds(previous =>\n      previous.length === ids.length &&\n      previous.every((id, index) => id === ids[index])\n        ? previous\n        : ids\n    );\n  }, []);\n\n  const register = useCallback(\n    (element: HTMLElement, registration: ChatMessageRegistration) => {\n      const { id, scrollAnchor } = registration;\n      if (id) {\n        registryMapRef.current.set(id, element);\n        intersectionObserverRef.current?.observe(element);\n      }\n      if (scrollAnchor && mountedRef.current) {\n        // Anchoring pauses following; the reply streams in below while the\n        // anchored message holds near the viewport top.\n        followingRef.current = false;\n        pendingAnchorRef.current = { element };\n        const viewport = viewportRef.current;\n        if (viewport) {\n          const contentEnd = viewport.scrollHeight - spacerHeightRef.current;\n          const below = contentEnd - offsetWithin(element);\n          const needed = Math.max(\n            0,\n            Math.round(viewport.clientHeight - anchorOffsetRef.current - below)\n          );\n          anchorShrinkRef.current = {\n            rawAtAnchor: contentEnd,\n            spacerAtAnchor: needed\n          };\n          setSpacer(needed);\n        }\n        setAnchorTick(tick => tick + 1);\n      }\n      return () => {\n        if (id) {\n          if (registryMapRef.current.get(id) === element) {\n            registryMapRef.current.delete(id);\n          }\n          intersectionObserverRef.current?.unobserve(element);\n          if (visibleSetRef.current.delete(id)) updateVisibleIds();\n        }\n      };\n    },\n    [offsetWithin, setSpacer, updateVisibleIds]\n  );\n\n  // Perform the pending anchor scroll after the spacer has been committed,\n  // so the target position exists before the frame paints.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on anchorTick — each anchor request bumps it.\n  useLayoutEffect(() => {\n    const pending = pendingAnchorRef.current;\n    const viewport = viewportRef.current;\n    if (!pending || !viewport) return;\n    pendingAnchorRef.current = null;\n    if (!pending.element.isConnected) return;\n    viewport.scrollTop = Math.max(\n      0,\n      offsetWithin(pending.element) - anchorOffsetRef.current\n    );\n    setAtBottom(isAtBottom());\n  }, [anchorTick, offsetWithin, isAtBottom, setAtBottom]);\n\n  // Start pinned to the live edge.\n  useLayoutEffect(() => {\n    const viewport = viewportRef.current;\n    if (viewport) viewport.scrollTop = viewport.scrollHeight;\n    mountedRef.current = true;\n  }, []);\n\n  // Keep the reading position stable when history is prepended above.\n  useLayoutEffect(() => {\n    const viewport = viewportRef.current;\n    const content = contentRef.current;\n    if (!viewport || !content) return;\n    let first = content.firstElementChild;\n    while (first && first.hasAttribute('data-chat-jump-button')) {\n      first = first.nextElementSibling;\n    }\n    const previous = prevFirstRef.current;\n    if (\n      previous &&\n      previous.element.isConnected &&\n      first !== previous.element &&\n      !atBottomRef.current\n    ) {\n      const delta = offsetWithin(previous.element) - previous.top;\n      if (delta > 0) viewport.scrollTop += delta;\n    }\n    prevFirstRef.current = first\n      ? { element: first, top: offsetWithin(first) }\n      : null;\n  });\n\n  // Scroll tracking: keep atBottom fresh and treat user scrolls as intent to\n  // follow (at bottom) or stop following (scrolled up).\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n    const handleScroll = () => {\n      const bottom = isAtBottom();\n      setAtBottom(bottom);\n      if (scrollingToBottomRef.current) {\n        if (bottom) scrollingToBottomRef.current = false;\n        return;\n      }\n      followingRef.current = bottom;\n    };\n    viewport.addEventListener('scroll', handleScroll, { passive: true });\n    return () => viewport.removeEventListener('scroll', handleScroll);\n  }, [isAtBottom, setAtBottom]);\n\n  // Follow growth while at the live edge; consume the anchor spacer as the\n  // reply streams into it so the blank space fills up instead of lingering.\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    const content = contentRef.current;\n    if (!viewport || !content || typeof ResizeObserver === 'undefined') return;\n    const resizeObserver = new ResizeObserver(() => {\n      const shrink = anchorShrinkRef.current;\n      if (shrink) {\n        const raw = content.offsetHeight - spacerHeightRef.current;\n        const next = Math.max(\n          0,\n          shrink.spacerAtAnchor - (raw - shrink.rawAtAnchor)\n        );\n        setSpacer(next);\n        if (next === 0) anchorShrinkRef.current = null;\n      }\n      if (autoScrollRef.current && followingRef.current) {\n        viewport.scrollTop = viewport.scrollHeight;\n        setAtBottom(true);\n      } else {\n        setAtBottom(isAtBottom());\n      }\n    });\n    resizeObserver.observe(content);\n    resizeObserver.observe(viewport);\n    return () => resizeObserver.disconnect();\n  }, [isAtBottom, setAtBottom, setSpacer]);\n\n  // Track which registered messages intersect the viewport.\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport || typeof IntersectionObserver === 'undefined') return;\n    const idOf = (element: Element) =>\n      element.getAttribute('data-message-id') ?? undefined;\n    const intersectionObserver = new IntersectionObserver(\n      entries => {\n        for (const entry of entries) {\n          const id = idOf(entry.target);\n          if (!id) continue;\n          if (entry.isIntersecting) visibleSetRef.current.add(id);\n          else visibleSetRef.current.delete(id);\n        }\n        updateVisibleIds();\n      },\n      { root: viewport, threshold: 0 }\n    );\n    intersectionObserverRef.current = intersectionObserver;\n    for (const element of registryMapRef.current.values()) {\n      intersectionObserver.observe(element);\n    }\n    return () => {\n      intersectionObserver.disconnect();\n      intersectionObserverRef.current = null;\n    };\n  }, [updateVisibleIds]);\n\n  const actions = useMemo<ChatMessagesActions>(\n    () => ({ scrollToBottom, scrollToMessage }),\n    [scrollToBottom, scrollToMessage]\n  );\n\n  useImperativeHandle(actionsRef, () => actions, [actions]);\n\n  const state = useMemo<ChatMessagesState>(\n    () => ({ atBottom, visibleMessageIds }),\n    [atBottom, visibleMessageIds]\n  );\n\n  const registry = useMemo<ChatMessagesRegistry>(\n    () => ({ register }),\n    [register]\n  );\n\n  return (\n    <ChatMessagesRegistryContext.Provider value={registry}>\n      <ChatMessagesActionsContext.Provider value={actions}>\n        <ChatMessagesStateContext.Provider value={state}>\n          <ScrollAreaPrimitive.Root\n            className={cx(styles.messages, className)}\n            data-slot='chat-messages'\n            {...props}\n          >\n            <ScrollAreaPrimitive.Viewport\n              ref={viewportRef}\n              className={styles['messages-viewport']}\n              role='log'\n              aria-label={ariaLabel}\n              data-slot='chat-messages-viewport'\n            >\n              <ScrollAreaPrimitive.Content\n                ref={contentRef}\n                className={styles['messages-content']}\n                data-slot='chat-messages-content'\n              >\n                {children}\n                {spacerHeight > 0 && (\n                  <div\n                    className={styles['messages-spacer']}\n                    style={{ height: spacerHeight }}\n                    aria-hidden='true'\n                    data-slot='chat-messages-spacer'\n                  />\n                )}\n              </ScrollAreaPrimitive.Content>\n            </ScrollAreaPrimitive.Viewport>\n            <ScrollAreaScrollbar\n              orientation='vertical'\n              type='hover'\n              data-slot='chat-messages-scrollbar'\n            />\n          </ScrollAreaPrimitive.Root>\n        </ChatMessagesStateContext.Provider>\n      </ChatMessagesActionsContext.Provider>\n    </ChatMessagesRegistryContext.Provider>\n  );\n}\n\nChatMessages.displayName = 'Chat.Messages';\n\nexport interface ChatJumpButtonProps extends ComponentProps<'button'> {\n  /**\n   * Icon rendered before the label. Pass `null` to remove it.\n   * @defaultValue an arrow-down icon\n   */\n  leadingIcon?: ReactNode;\n}\n\nexport function ChatJumpButton({\n  className,\n  children,\n  onClick,\n  leadingIcon,\n  ...props\n}: ChatJumpButtonProps) {\n  const { atBottom } = useChatMessagesState('Chat.JumpButton');\n  const { scrollToBottom } = useChatMessagesActions('Chat.JumpButton');\n\n  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {\n    onClick?.(event);\n    if (event.defaultPrevented) return;\n    scrollToBottom('smooth');\n  };\n\n  return (\n    <button\n      type='button'\n      data-chat-jump-button=''\n      data-active={!atBottom || undefined}\n      tabIndex={atBottom ? -1 : 0}\n      aria-hidden={atBottom || undefined}\n      className={cx(styles['jump-button'], className)}\n      onClick={handleClick}\n      data-slot='chat-jump-button'\n      {...props}\n    >\n      {leadingIcon !== null && (\n        <span\n          className={styles['jump-icon']}\n          aria-hidden='true'\n          data-slot='chat-jump-button-icon'\n        >\n          {leadingIcon ?? <ArrowDownIcon />}\n        </span>\n      )}\n      {children ?? 'Latest'}\n    </button>\n  );\n}\n\nChatJumpButton.displayName = 'Chat.JumpButton';\n"],"names":[],"mappings":";;;;;;;;;;;;;;AAgEgB;AAUd;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;;AAIA;AACA;;;AAOA;AAEA;AACA;;AAEA;AACA;AAEA;AACA;AACA;AAEA;AACE;;AACA;;;AAIF;AACE;AACA;AAAe;;;;;AAQjB;AACE;AACA;AAAe;AACf;AAEE;;;AAKJ;AACE;;AACA;;;;AAME;AACA;;AACA;;;;AAGE;;;;AAQF;AACA;;AACA;AACA;AACE;;;AAEyB;AAC3B;;AAEF;;AAME;;AAEA;;AACA;AACA;AAIF;AAIF;AACE;;;;;AAKE;AAA4B;AAC5B;AACE;;;AAGJ;;AAGE;AACE;;;;AAOF;;;AAGE;;AAEF;;;AAGE;AACA;AACA;;;;;;AASI;AACA;;;;;;AAMN;;;AAGM;;AAEF;AACA;AAAsC;;AAE1C;;;;;;AASF;AACA;AACA;;AACA;AACA;;;AAKA;;;;AAKA;AACA;AAAc;AACd;;;;AAKA;AACA;AACA;;AACA;;AAEE;;AAEF;AACA;;;AAIE;AAEA;;AACe;;;AAGf;;AAEJ;;;;AAKE;AACA;;;AAEE;;AAEA;AACE;AAAY;;;AAGd;AACF;AACA;;AAEF;;;;AAKE;AACA;;;AAEA;AACE;;;;;;AAQkB;;;AAGhB;;;;AAGA;;AAEJ;AACA;AACA;AACA;;;;AAKA;AACA;;AACA;AAEA;AAEI;;AAEE;;;AAC0B;;AACrB;;AAEP;;AAIJ;;AAEE;;AAEF;;AAEE;AACF;AACF;;AAOA;;AAOA;AAKA;AA0CF;AAEA;AAUgB;;;AAUd;AACE;;;;AAGF;AAEA;AAwBF;AAEA;;;"}