{
  "name": "message-scroller",
  "type": "registry:ui",
  "dependencies": [
    "@lucide/vue"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "ui/message-scroller/MessageScroller.vue",
      "type": "registry:ui",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessageScrollerContext } from \"./useMessageScroller\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n}>()\n\nconst { autoscrolling, scrollableAttr } = useMessageScrollerContext()\n</script>\n\n<template>\n  <div\n    data-slot=\"message-scroller\"\n    :data-scrollable=\"scrollableAttr\"\n    :data-autoscrolling=\"autoscrolling ? '' : undefined\"\n    :class=\"cn(\n      'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',\n      props.class,\n    )\"\n  >\n    <slot />\n  </div>\n</template>\n"
    },
    {
      "path": "ui/message-scroller/MessageScrollerButton.vue",
      "type": "registry:ui",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport type { MessageScrollerButtonDirection } from \"./useMessageScroller\"\nimport type { ButtonVariants } from \"@/components/ui/button\"\nimport { ArrowDownIcon } from \"@lucide/vue\"\nimport { computed } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { useMessageScroller, useMessageScrollerScrollable } from \"./useMessageScroller\"\n\nconst props = withDefaults(defineProps<{\n  class?: HTMLAttributes[\"class\"]\n  direction?: MessageScrollerButtonDirection\n  behavior?: ScrollBehavior\n  variant?: ButtonVariants[\"variant\"]\n  size?: ButtonVariants[\"size\"]\n}>(), {\n  direction: \"end\",\n  behavior: \"smooth\",\n  variant: \"secondary\",\n  size: \"icon-sm\",\n})\n\nconst { scrollToEnd, scrollToStart } = useMessageScroller()\nconst scrollable = useMessageScrollerScrollable()\n\nconst active = computed(() =>\n  props.direction === \"start\" ? scrollable.value.start : scrollable.value.end)\n\nfunction onClick(event: MouseEvent) {\n  if (!active.value)\n    return\n  const target = event.currentTarget as HTMLElement | null\n  target?.blur()\n  if (event.defaultPrevented)\n    return\n  if (props.direction === \"start\")\n    scrollToStart({ behavior: props.behavior })\n  else\n    scrollToEnd({ behavior: props.behavior })\n}\n</script>\n\n<template>\n  <Button\n    data-slot=\"message-scroller-button\"\n    :data-direction=\"direction\"\n    :data-active=\"active ? 'true' : 'false'\"\n    :variant=\"variant\"\n    :size=\"size\"\n    :inert=\"!active\"\n    :tabindex=\"active ? undefined : -1\"\n    :class=\"cn(\n      'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',\n      props.class,\n    )\"\n    @click=\"onClick\"\n  >\n    <slot>\n      <ArrowDownIcon />\n      <span class=\"sr-only\">{{ direction === \"end\" ? \"Scroll to end\" : \"Scroll to start\" }}</span>\n    </slot>\n  </Button>\n</template>\n"
    },
    {
      "path": "ui/message-scroller/MessageScrollerContent.vue",
      "type": "registry:ui",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { onBeforeUnmount, onMounted, useTemplateRef } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessageScrollerContext } from \"./useMessageScroller\"\n\nconst props = defineProps<{\n  class?: HTMLAttributes[\"class\"]\n  spacerClass?: HTMLAttributes[\"class\"]\n}>()\n\nconst {\n  handleContentChange,\n  handleResize,\n  setContentElement,\n  setSpacerElement,\n} = useMessageScrollerContext()\n\nconst contentRef = useTemplateRef<HTMLElement>(\"content\")\nconst spacerRef = useTemplateRef<HTMLElement>(\"spacer\")\n\nlet mutationObserver: MutationObserver | null = null\nlet resizeObserver: ResizeObserver | null = null\nlet resizeFrame = 0\n\nonMounted(() => {\n  const content = contentRef.value\n  if (!content)\n    return\n\n  setContentElement(content)\n  setSpacerElement(spacerRef.value ?? null)\n  handleContentChange()\n\n  if (typeof MutationObserver !== \"undefined\") {\n    mutationObserver = new MutationObserver(() => handleContentChange())\n    mutationObserver.observe(content, { childList: true })\n  }\n\n  if (typeof ResizeObserver !== \"undefined\") {\n    resizeObserver = new ResizeObserver(() => {\n      window.cancelAnimationFrame(resizeFrame)\n      resizeFrame = window.requestAnimationFrame(handleResize)\n    })\n    resizeObserver.observe(content)\n  }\n})\n\nonBeforeUnmount(() => {\n  window.cancelAnimationFrame(resizeFrame)\n  mutationObserver?.disconnect()\n  resizeObserver?.disconnect()\n  mutationObserver = null\n  resizeObserver = null\n  setContentElement(null)\n  setSpacerElement(null)\n})\n</script>\n\n<template>\n  <div\n    ref=\"content\"\n    data-slot=\"message-scroller-content\"\n    role=\"log\"\n    aria-relevant=\"additions\"\n    :class=\"cn('flex h-max min-h-full flex-col gap-8', props.class)\"\n  >\n    <slot />\n    <div\n      ref=\"spacer\"\n      aria-hidden=\"true\"\n      data-message-scroller-spacer=\"\"\n      hidden\n      :class=\"props.spacerClass\"\n    />\n  </div>\n</template>\n"
    },
    {
      "path": "ui/message-scroller/MessageScrollerItem.vue",
      "type": "registry:ui",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { onBeforeUnmount, onMounted, useTemplateRef, watch } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessageScrollerRegister } from \"./useMessageScroller\"\n\nconst props = withDefaults(defineProps<{\n  messageId?: string\n  scrollAnchor?: boolean\n  class?: HTMLAttributes[\"class\"]\n}>(), {\n  scrollAnchor: false,\n})\n\nconst register = useMessageScrollerRegister()\n\nconst itemEl = useTemplateRef<HTMLElement>(\"item\")\n\nonMounted(() => {\n  if (props.messageId && itemEl.value)\n    register(props.messageId, itemEl.value, null)\n})\n\nwatch(() => props.messageId, (messageId, previousMessageId) => {\n  const element = itemEl.value\n  if (!element)\n    return\n  if (previousMessageId)\n    register(previousMessageId, null, element)\n  if (messageId)\n    register(messageId, element, null)\n})\n\nonBeforeUnmount(() => {\n  if (props.messageId && itemEl.value)\n    register(props.messageId, null, itemEl.value)\n})\n</script>\n\n<template>\n  <div\n    ref=\"item\"\n    data-slot=\"message-scroller-item\"\n    :data-message-id=\"messageId\"\n    :data-scroll-anchor=\"scrollAnchor ? 'true' : 'false'\"\n    :class=\"cn(\n      'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]',\n      props.class,\n    )\"\n  >\n    <slot />\n  </div>\n</template>\n"
    },
    {
      "path": "ui/message-scroller/MessageScrollerProvider.vue",
      "type": "registry:ui",
      "content": "<script setup lang=\"ts\">\nimport type { MessageScrollerProviderProps } from \"./useMessageScroller\"\nimport { provideMessageScroller } from \"./useMessageScroller\"\n\nconst props = defineProps<MessageScrollerProviderProps>()\n\nprovideMessageScroller(props)\n</script>\n\n<template>\n  <slot />\n</template>\n"
    },
    {
      "path": "ui/message-scroller/MessageScrollerViewport.vue",
      "type": "registry:ui",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from \"vue\"\nimport { onBeforeUnmount, onMounted, useTemplateRef, watch } from \"vue\"\nimport { cn } from \"@/lib/utils\"\nimport { SCROLL_KEYS, useMessageScrollerContext } from \"./useMessageScroller\"\n\nconst props = withDefaults(defineProps<{\n  class?: HTMLAttributes[\"class\"]\n  preserveScrollOnPrepend?: boolean\n}>(), {\n  preserveScrollOnPrepend: true,\n})\n\nconst {\n  autoscrolling,\n  handleResize,\n  scrollableAttr,\n  setPreserveScrollOnPrepend,\n  setViewportElement,\n  syncAfterScroll,\n  userScrollIntent,\n} = useMessageScrollerContext()\n\nconst viewportEl = useTemplateRef<HTMLElement>(\"viewport\")\n\nwatch(() => props.preserveScrollOnPrepend, setPreserveScrollOnPrepend, { immediate: true })\n\nfunction onKeyDown(event: KeyboardEvent) {\n  if (SCROLL_KEYS.has(event.key))\n    userScrollIntent()\n}\n\nlet resizeObserver: ResizeObserver | null = null\nlet resizeFrame = 0\n\nonMounted(() => {\n  const viewport = viewportEl.value\n  setViewportElement(viewport)\n  if (!viewport || typeof ResizeObserver === \"undefined\")\n    return\n  resizeObserver = new ResizeObserver(() => {\n    window.cancelAnimationFrame(resizeFrame)\n    resizeFrame = window.requestAnimationFrame(handleResize)\n  })\n  resizeObserver.observe(viewport)\n})\n\nonBeforeUnmount(() => {\n  window.cancelAnimationFrame(resizeFrame)\n  resizeObserver?.disconnect()\n  resizeObserver = null\n  setViewportElement(null)\n})\n</script>\n\n<template>\n  <div\n    ref=\"viewport\"\n    data-slot=\"message-scroller-viewport\"\n    role=\"region\"\n    aria-label=\"Messages\"\n    :tabindex=\"0\"\n    :data-scrollable=\"scrollableAttr\"\n    :data-autoscrolling=\"autoscrolling ? '' : undefined\"\n    :class=\"cn(\n      'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent',\n      props.class,\n    )\"\n    @scroll=\"syncAfterScroll()\"\n    @wheel=\"userScrollIntent()\"\n    @touchmove=\"userScrollIntent()\"\n    @keydown=\"onKeyDown\"\n  >\n    <slot />\n  </div>\n</template>\n"
    },
    {
      "path": "ui/message-scroller/index.ts",
      "type": "registry:ui",
      "content": "export { default as MessageScroller } from \"./MessageScroller.vue\"\nexport { default as MessageScrollerButton } from \"./MessageScrollerButton.vue\"\nexport { default as MessageScrollerContent } from \"./MessageScrollerContent.vue\"\nexport { default as MessageScrollerItem } from \"./MessageScrollerItem.vue\"\nexport { default as MessageScrollerProvider } from \"./MessageScrollerProvider.vue\"\nexport { default as MessageScrollerViewport } from \"./MessageScrollerViewport.vue\"\n\nexport type {\n  MessageScrollerButtonDirection,\n  MessageScrollerDefaultScrollPosition,\n  MessageScrollerProviderProps,\n  MessageScrollerScrollable,\n  MessageScrollerScrollAlign,\n  MessageScrollerScrollOptions,\n  MessageScrollerVisibilityState,\n} from \"./useMessageScroller\"\n\nexport {\n  useMessageScroller,\n  useMessageScrollerScrollable,\n  useMessageScrollerVisibility,\n} from \"./useMessageScroller\"\n"
    },
    {
      "path": "ui/message-scroller/useMessageScroller.ts",
      "type": "registry:ui",
      "content": "import type { ComputedRef, InjectionKey, Ref, ShallowRef } from \"vue\"\nimport { computed, getCurrentScope, inject, onMounted, onScopeDispose, provide, shallowRef, watch } from \"vue\"\n\n// -----------------------------------------------------------------------------\n// Public types\n// -----------------------------------------------------------------------------\n\nexport type MessageScrollerDefaultScrollPosition = \"start\" | \"end\" | \"last-anchor\"\nexport type MessageScrollerButtonDirection = \"start\" | \"end\"\nexport type MessageScrollerScrollAlign = \"start\" | \"center\" | \"end\" | \"nearest\"\n\nexport interface MessageScrollerScrollOptions {\n  align?: MessageScrollerScrollAlign\n  behavior?: ScrollBehavior\n  scrollMargin?: number\n}\n\nexport interface MessageScrollerScrollable {\n  start: boolean\n  end: boolean\n}\n\nexport interface MessageScrollerVisibilityState {\n  currentAnchorId: string | null\n  visibleMessageIds: string[]\n}\n\nexport interface MessageScrollerProviderProps {\n  autoScroll?: boolean\n  defaultScrollPosition?: MessageScrollerDefaultScrollPosition\n  scrollEdgeThreshold?: number\n  scrollPreviousItemPeek?: number\n  scrollMargin?: number\n}\n\n// -----------------------------------------------------------------------------\n// Constants\n// -----------------------------------------------------------------------------\n\nconst DEFAULT_SCROLL_EDGE_THRESHOLD = 8\nconst DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64\nconst DEFAULT_SCROLL_MARGIN = 0\nconst SCROLL_EPSILON = 0.5\nconst AUTOSCROLLING_TIMEOUT = 180\n\nconst SCROLL_KEYS = new Set([\n  \"ArrowDown\",\n  \"ArrowUp\",\n  \"End\",\n  \"Home\",\n  \"PageDown\",\n  \"PageUp\",\n  \" \",\n])\n\nconst EMPTY_SCROLLABLE: MessageScrollerScrollable = { start: false, end: false }\nconst EMPTY_VISIBLE_IDS: string[] = []\nconst EMPTY_VISIBILITY: MessageScrollerVisibilityState = {\n  currentAnchorId: null,\n  visibleMessageIds: EMPTY_VISIBLE_IDS,\n}\n\ntype Mode\n  = | \"following-bottom\"\n    | \"free-scrolling\"\n    | \"anchored-to-message\"\n    | \"settling-jump\"\n\ninterface PrependRestore {\n  element: HTMLElement\n  viewportTop: number\n}\n\ninterface PendingScrollToMessage {\n  messageId: string\n  options?: MessageScrollerScrollOptions\n}\n\nfunction scrollableEqual(a: MessageScrollerScrollable, b: MessageScrollerScrollable) {\n  return a.start === b.start && a.end === b.end\n}\n\nfunction visibilityEqual(\n  a: MessageScrollerVisibilityState,\n  b: MessageScrollerVisibilityState,\n) {\n  if (\n    a.currentAnchorId !== b.currentAnchorId\n    || a.visibleMessageIds.length !== b.visibleMessageIds.length\n  ) {\n    return false\n  }\n  return a.visibleMessageIds.every((id, index) => id === b.visibleMessageIds[index])\n}\n\n// -----------------------------------------------------------------------------\n// DOM measurement helpers\n// -----------------------------------------------------------------------------\n\nfunction parseNumber(value: string | null | undefined): number {\n  if (!value)\n    return 0\n  const parsed = Number.parseFloat(value)\n  return Number.isFinite(parsed) ? parsed : 0\n}\n\nfunction getPadding(element: HTMLElement): { start: number, end: number } {\n  const style = window.getComputedStyle(element)\n  return {\n    end: parseNumber(style.paddingBlockEnd || style.paddingBottom),\n    start: parseNumber(style.paddingBlockStart || style.paddingTop),\n  }\n}\n\nfunction getContentPadding(spacer: HTMLElement | null): { start: number, end: number } {\n  const parent = spacer?.parentElement\n  return parent ? getPadding(parent) : { end: 0, start: 0 }\n}\n\nfunction getRowGap(element: HTMLElement | null): number {\n  if (!element)\n    return 0\n  const style = window.getComputedStyle(element)\n  const gap = style.rowGap === \"normal\" ? style.gap : style.rowGap\n  return parseNumber(gap)\n}\n\nfunction getMessageChildren(\n  content: HTMLElement,\n  spacer: HTMLElement | null,\n): HTMLElement[] {\n  return Array.from(content.children).filter(\n    (child): child is HTMLElement =>\n      child instanceof HTMLElement && child !== spacer,\n  )\n}\n\nfunction getElementOffsetTop(element: HTMLElement, viewport: HTMLElement): number {\n  const elementRect = element.getBoundingClientRect()\n  const viewportRect = viewport.getBoundingClientRect()\n  return elementRect.top - viewportRect.top + viewport.scrollTop\n}\n\nfunction getRelativeTop(element: HTMLElement, viewport: HTMLElement): number {\n  return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top\n}\n\nfunction measureContentHeight({\n  content,\n  spacer,\n  viewport,\n}: {\n  content: HTMLElement\n  spacer: HTMLElement | null\n  viewport: HTMLElement\n}): number {\n  const children = getMessageChildren(content, spacer)\n  const padding = getPadding(content)\n  const viewportRect = viewport.getBoundingClientRect()\n  const scrollTop = viewport.scrollTop\n  let height = padding.start + padding.end\n  for (const child of children) {\n    const rect = child.getBoundingClientRect()\n    height = Math.max(height, rect.bottom - viewportRect.top + scrollTop + padding.end)\n  }\n  return height\n}\n\nfunction maxScrollTop(element: HTMLElement): number {\n  return Math.max(0, element.scrollHeight - element.clientHeight)\n}\n\nfunction computeSpacerHeight({\n  content,\n  scrollTop,\n  spacer,\n  viewport,\n}: {\n  content: HTMLElement\n  scrollTop: number\n  spacer: HTMLElement | null\n  viewport: HTMLElement\n}): number {\n  const contentHeight = measureContentHeight({ content, spacer, viewport })\n  return scrollTop + viewport.clientHeight - contentHeight\n}\n\nfunction computeScrollTopForElement({\n  align,\n  element,\n  scrollMargin,\n  spacer,\n  viewport,\n}: {\n  align: MessageScrollerScrollAlign\n  element: HTMLElement\n  scrollMargin: number\n  spacer: HTMLElement | null\n  viewport: HTMLElement\n}): number {\n  const offsetTop = getElementOffsetTop(element, viewport)\n  const height = element.getBoundingClientRect().height\n  const padding = getContentPadding(spacer)\n\n  if (align === \"center\") {\n    const available = Math.max(0, viewport.clientHeight - padding.start - padding.end)\n    return offsetTop - padding.start - (available - height) / 2 - scrollMargin\n  }\n  if (align === \"end\")\n    return offsetTop - viewport.clientHeight + height + padding.end + scrollMargin\n  if (align === \"nearest\") {\n    const bottom = offsetTop + height\n    const visibleTop = viewport.scrollTop + padding.start\n    const visibleBottom = viewport.scrollTop + viewport.clientHeight - padding.end\n    if (offsetTop >= visibleTop && bottom <= visibleBottom)\n      return viewport.scrollTop\n    return offsetTop < visibleTop\n      ? offsetTop - padding.start - scrollMargin\n      : bottom - viewport.clientHeight + padding.end + scrollMargin\n  }\n  return offsetTop - padding.start - scrollMargin\n}\n\nfunction computeScrollable({\n  content,\n  scrollEdgeThreshold,\n  spacer,\n  viewport,\n}: {\n  content: HTMLElement | null\n  scrollEdgeThreshold: number\n  spacer: HTMLElement | null\n  viewport: HTMLElement | null\n}): MessageScrollerScrollable {\n  if (!viewport || !content)\n    return EMPTY_SCROLLABLE\n  const contentHeight = measureContentHeight({ content, spacer, viewport })\n  return {\n    start: viewport.scrollTop > scrollEdgeThreshold,\n    end: contentHeight - viewport.scrollTop - viewport.clientHeight > scrollEdgeThreshold,\n  }\n}\n\nfunction computeVisibility({\n  content,\n  scrollMargin,\n  scrollPreviousItemPeek,\n  spacer,\n  viewport,\n  visibleMessageIds,\n}: {\n  content: HTMLElement | null\n  scrollMargin: number\n  scrollPreviousItemPeek: number\n  spacer: HTMLElement | null\n  viewport: HTMLElement | null\n  visibleMessageIds: Set<string>\n}): MessageScrollerVisibilityState {\n  if (!content || !viewport)\n    return EMPTY_VISIBILITY\n  const viewportRect = viewport.getBoundingClientRect()\n  const anchorLine = viewportRect.top + scrollMargin + scrollPreviousItemPeek\n  const noIntersectionObserver = typeof IntersectionObserver === \"undefined\"\n  const visible: string[] = []\n  let currentAnchorId: string | null = null\n\n  for (const child of getMessageChildren(content, spacer)) {\n    const messageId = child.dataset.messageId\n    if (!messageId)\n      continue\n    const isAnchor = child.dataset.scrollAnchor === \"true\"\n    const rect = isAnchor || noIntersectionObserver ? child.getBoundingClientRect() : null\n    const isVisible = noIntersectionObserver && rect\n      ? rect.bottom > anchorLine && rect.top < viewportRect.bottom\n      : visibleMessageIds.has(messageId)\n    if (isVisible)\n      visible.push(messageId)\n    if (isAnchor && rect && rect.top <= anchorLine + SCROLL_EPSILON)\n      currentAnchorId = messageId\n  }\n\n  return visible.length === 0 && currentAnchorId === null\n    ? EMPTY_VISIBILITY\n    : { currentAnchorId, visibleMessageIds: visible }\n}\n\nfunction findFirstAnchorFrom(\n  elements: HTMLElement[],\n  startIndex: number,\n): HTMLElement | null {\n  for (let i = startIndex; i < elements.length; i++) {\n    const element = elements[i]\n    if (element?.dataset.scrollAnchor === \"true\")\n      return element\n  }\n  return null\n}\n\nfunction findFirstUnhandledAnchor(\n  elements: HTMLElement[],\n  handled: WeakSet<HTMLElement>,\n): HTMLElement | null {\n  for (const element of elements) {\n    if (element.dataset.scrollAnchor === \"true\" && !handled.has(element))\n      return element\n  }\n  return null\n}\n\nfunction hasMultipleAnchorsFrom(\n  elements: HTMLElement[],\n  startIndex: number,\n): boolean {\n  let count = 0\n  for (let i = startIndex; i < elements.length; i++) {\n    if (elements[i]?.dataset.scrollAnchor === \"true\") {\n      count += 1\n      if (count > 1)\n        return true\n    }\n  }\n  return false\n}\n\nfunction findLastAnchor(elements: HTMLElement[]): HTMLElement | null {\n  for (let i = elements.length - 1; i >= 0; i--) {\n    const element = elements[i]\n    if (element?.dataset.scrollAnchor === \"true\")\n      return element\n  }\n  return null\n}\n\nfunction findFirstVisibleMessage({\n  content,\n  spacer,\n  viewport,\n}: {\n  content: HTMLElement\n  spacer: HTMLElement | null\n  viewport: HTMLElement\n}): HTMLElement | null {\n  const viewportRect = viewport.getBoundingClientRect()\n  for (const child of getMessageChildren(content, spacer)) {\n    if (!child.dataset.messageId)\n      continue\n    const rect = child.getBoundingClientRect()\n    if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom)\n      return child\n  }\n  return null\n}\n\n// -----------------------------------------------------------------------------\n// Engine\n// -----------------------------------------------------------------------------\n\nexport interface MessageScrollerContext {\n  autoscrolling: Readonly<ShallowRef<boolean>>\n  scrollable: Readonly<ShallowRef<MessageScrollerScrollable>>\n  scrollableAttr: ComputedRef<string | undefined>\n  visibility: Readonly<ShallowRef<MessageScrollerVisibilityState>>\n  acquireVisibility: () => void\n  releaseVisibility: () => void\n  handleContentChange: () => void\n  handleResize: () => void\n  scrollToEnd: (options?: { behavior?: ScrollBehavior }) => boolean\n  scrollToMessage: (messageId: string, options?: MessageScrollerScrollOptions) => boolean\n  scrollToStart: (options?: { behavior?: ScrollBehavior }) => boolean\n  setContentElement: (element: HTMLElement | null) => void\n  setSpacerElement: (element: HTMLElement | null) => void\n  setViewportElement: (element: HTMLElement | null) => void\n  setPreserveScrollOnPrepend: (value: boolean) => void\n  syncAfterScroll: () => void\n  userScrollIntent: () => void\n}\n\nexport type RegisterMessage = (\n  messageId: string,\n  element: HTMLElement | null,\n  previousElement: HTMLElement | null,\n) => void\n\nconst CONTEXT_KEY: InjectionKey<MessageScrollerContext> = Symbol(\"MessageScrollerContext\")\nconst REGISTER_KEY: InjectionKey<RegisterMessage> = Symbol(\"MessageScrollerRegister\")\n\nfunction createEngine(props: MessageScrollerProviderProps) {\n  const autoScroll = () => props.autoScroll ?? false\n  const defaultScrollPosition = () => props.defaultScrollPosition ?? \"end\"\n  const scrollEdgeThreshold = () => props.scrollEdgeThreshold ?? DEFAULT_SCROLL_EDGE_THRESHOLD\n  const scrollPreviousItemPeek = () => props.scrollPreviousItemPeek ?? DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK\n  const scrollMargin = () => props.scrollMargin ?? DEFAULT_SCROLL_MARGIN\n\n  let viewport: HTMLElement | null = null\n  let content: HTMLElement | null = null\n  let spacer: HTMLElement | null = null\n  let spacerGap = 0\n  let spacerHeight = 0\n  let mode: Mode = autoScroll() ? \"following-bottom\" : \"free-scrolling\"\n  let streamingTurn: HTMLElement | null = null\n  let firstItem: HTMLElement | null = null\n  let itemCount = 0\n  let lastScrollTop = 0\n  let defaultScrollPositionApplied = false\n  let preserveScrollOnPrepend = true\n  let prependRestore: PrependRestore | null = null\n  let pendingScrollToMessage: PendingScrollToMessage | null = null\n  let stateFrame: number | null = null\n  let visibilityFrame: number | null = null\n  let pendingScrollFrame: number | null = null\n  let autoscrollingTimeout: number | null = null\n  let visibilityObserver: IntersectionObserver | null = null\n  let visibilityConsumers = 0\n  const messageElements = new Map<string, HTMLElement>()\n  const visibleMessageIds = new Set<string>()\n  const handledScrollAnchors = new WeakSet<HTMLElement>()\n\n  const autoscrolling = shallowRef(false)\n  const scrollable = shallowRef<MessageScrollerScrollable>(EMPTY_SCROLLABLE)\n  const visibility = shallowRef<MessageScrollerVisibilityState>(EMPTY_VISIBILITY)\n  const scrollableAttr = computed(() => {\n    const attr = [scrollable.value.start && \"start\", scrollable.value.end && \"end\"]\n      .filter(Boolean)\n      .join(\" \")\n    return attr || undefined\n  })\n\n  // --- scroll-state commit / visibility scheduling ---------------------------\n\n  function updateModeFromScroll(next: MessageScrollerScrollable) {\n    const scrollTop = viewport?.scrollTop ?? 0\n    const scrolledUp = scrollTop < lastScrollTop - SCROLL_EPSILON\n    lastScrollTop = scrollTop\n    if (\n      autoScroll()\n      && !next.end\n      && mode !== \"settling-jump\"\n      && mode !== \"anchored-to-message\"\n    ) {\n      mode = \"following-bottom\"\n    }\n    else if (\n      mode === \"following-bottom\"\n      && next.end\n      && scrolledUp\n      && !autoscrolling.value\n    ) {\n      mode = \"free-scrolling\"\n    }\n  }\n\n  function commitScrollState() {\n    const measured = computeScrollable({\n      content,\n      scrollEdgeThreshold: scrollEdgeThreshold(),\n      spacer,\n      viewport,\n    })\n    updateModeFromScroll(measured)\n    const next = mode === \"following-bottom\"\n      ? { ...measured, end: false }\n      : measured\n    if (!scrollableEqual(scrollable.value, next))\n      scrollable.value = next\n  }\n\n  function scheduleStateCommit() {\n    if (stateFrame === null) {\n      stateFrame = window.requestAnimationFrame(() => {\n        stateFrame = null\n        commitScrollState()\n      })\n    }\n  }\n\n  function scheduleVisibilitySync() {\n    if (visibilityConsumers === 0)\n      return\n    if (visibilityFrame === null) {\n      visibilityFrame = window.requestAnimationFrame(() => {\n        visibilityFrame = null\n        if (visibilityConsumers > 0) {\n          const next = computeVisibility({\n            content,\n            scrollMargin: scrollMargin(),\n            scrollPreviousItemPeek: scrollPreviousItemPeek(),\n            spacer,\n            viewport,\n            visibleMessageIds,\n          })\n          if (!visibilityEqual(visibility.value, next))\n            visibility.value = next\n        }\n      })\n    }\n  }\n\n  // --- imperative scroll primitives ------------------------------------------\n\n  function setAutoscrolling(active: boolean) {\n    if (autoscrollingTimeout !== null) {\n      window.clearTimeout(autoscrollingTimeout)\n      autoscrollingTimeout = null\n    }\n    if (autoscrolling.value !== active) {\n      autoscrolling.value = active\n      commitScrollState()\n    }\n    if (active) {\n      autoscrollingTimeout = window.setTimeout(() => {\n        autoscrollingTimeout = null\n        autoscrolling.value = false\n        commitScrollState()\n      }, AUTOSCROLLING_TIMEOUT)\n    }\n  }\n\n  function setSpacerHeight(height: number) {\n    if (!spacer)\n      return\n    const next = Math.max(0, Math.ceil(height))\n    if (spacerHeight !== next) {\n      spacerHeight = next\n      spacer.hidden = next === 0\n      spacer.style.height = `${next}px`\n      spacer.style.marginTop = next > 0 ? `${-spacerGap}px` : \"\"\n    }\n  }\n\n  function scrollTo(\n    top: number,\n    { behavior = \"auto\", autoscrolling: isAutoscrolling = false }: { behavior?: ScrollBehavior, autoscrolling?: boolean } = {},\n  ) {\n    if (!viewport)\n      return\n    const target = Math.max(0, top)\n    if (Math.abs(viewport.scrollTop - target) <= SCROLL_EPSILON) {\n      viewport.scrollTop = target\n      commitScrollState()\n      return\n    }\n    if (isAutoscrolling)\n      setAutoscrolling(true)\n    viewport.scrollTo({ top: target, behavior })\n    scheduleStateCommit()\n  }\n\n  function scrollToStart({ behavior = \"auto\" }: { behavior?: ScrollBehavior } = {}): boolean {\n    if (!viewport)\n      return false\n    setSpacerHeight(0)\n    streamingTurn = null\n    mode = \"free-scrolling\"\n    scrollTo(0, { behavior })\n    scheduleVisibilitySync()\n    return true\n  }\n\n  function scrollToEnd({ behavior = \"auto\" }: { behavior?: ScrollBehavior } = {}): boolean {\n    if (!viewport)\n      return false\n    setSpacerHeight(0)\n    streamingTurn = null\n    mode = autoScroll() ? \"following-bottom\" : \"free-scrolling\"\n    scrollTo(maxScrollTop(viewport), { autoscrolling: true, behavior })\n    scheduleVisibilitySync()\n    return true\n  }\n\n  function scrollToElement(\n    element: HTMLElement,\n    {\n      align = \"start\",\n      behavior = \"auto\",\n      scrollMargin: margin = scrollMargin(),\n    }: MessageScrollerScrollOptions = {},\n    { keepPreviousPeek = false }: { keepPreviousPeek?: boolean } = {},\n  ): boolean {\n    if (!content || !viewport || !content.contains(element))\n      return false\n    const targetScrollTop = computeScrollTopForElement({\n      align,\n      element,\n      scrollMargin: keepPreviousPeek ? margin + scrollPreviousItemPeek() : margin,\n      spacer,\n      viewport,\n    })\n    setSpacerHeight(computeSpacerHeight({\n      content,\n      scrollTop: targetScrollTop,\n      spacer,\n      viewport,\n    }))\n    prependRestore = { element, viewportTop: getRelativeTop(element, viewport) }\n    mode = keepPreviousPeek ? \"anchored-to-message\" : \"settling-jump\"\n    streamingTurn = keepPreviousPeek ? element : null\n    scrollTo(targetScrollTop, { behavior })\n    scheduleVisibilitySync()\n    return true\n  }\n\n  function reanchorToAnchoredMessage(): boolean {\n    if (!streamingTurn || !streamingTurn.isConnected || mode !== \"anchored-to-message\")\n      return false\n    return scrollToElement(streamingTurn, { align: \"start\" }, { keepPreviousPeek: true })\n  }\n\n  function scrollToMessage(\n    messageId: string,\n    options?: MessageScrollerScrollOptions,\n  ): boolean {\n    const element = messageElements.get(messageId)\n    if (element) {\n      defaultScrollPositionApplied = true\n      if (scrollToElement(element, options)) {\n        pendingScrollToMessage = null\n        return true\n      }\n      pendingScrollToMessage = { messageId, options }\n      return true\n    }\n    if (itemCount === 0) {\n      pendingScrollToMessage = { messageId, options }\n      defaultScrollPositionApplied = true\n      return true\n    }\n    return false\n  }\n\n  function flushPendingScrollToMessage(): boolean {\n    if (!pendingScrollToMessage)\n      return false\n    const element = messageElements.get(pendingScrollToMessage.messageId)\n    if (!element || !scrollToElement(element, pendingScrollToMessage.options))\n      return false\n    pendingScrollToMessage = null\n    defaultScrollPositionApplied = true\n    return true\n  }\n\n  // --- prepend preservation --------------------------------------------------\n\n  function applyPrependRestore(): boolean {\n    if (!prependRestore || !viewport || !prependRestore.element.isConnected)\n      return false\n    const delta = getRelativeTop(prependRestore.element, viewport) - prependRestore.viewportTop\n    if (Math.abs(delta) <= SCROLL_EPSILON)\n      return false\n    viewport.scrollTop += delta\n    prependRestore.viewportTop = getRelativeTop(prependRestore.element, viewport)\n    scheduleStateCommit()\n    scheduleVisibilitySync()\n    return true\n  }\n\n  function capturePrependAnchor() {\n    if (!content || !viewport) {\n      prependRestore = null\n      return\n    }\n    const element = findFirstVisibleMessage({ content, spacer, viewport })\n    prependRestore = element\n      ? { element, viewportTop: getRelativeTop(element, viewport) }\n      : null\n  }\n\n  function schedulePrependFlush() {\n    if (pendingScrollFrame === null) {\n      pendingScrollFrame = window.requestAnimationFrame(() => {\n        pendingScrollFrame = null\n        if (flushPendingScrollToMessage())\n          capturePrependAnchor()\n      })\n    }\n  }\n\n  // --- default scroll position -----------------------------------------------\n\n  function applyDefaultScrollPosition(): boolean {\n    if (defaultScrollPositionApplied || itemCount === 0)\n      return false\n    const position = defaultScrollPosition()\n    let applied = false\n    if (position === \"last-anchor\") {\n      const lastAnchor = content && viewport\n        ? findLastAnchor(getMessageChildren(content, spacer))\n        : null\n      if (!content || !viewport || !lastAnchor) {\n        applied = scrollToEnd({ behavior: \"auto\" })\n      }\n      else {\n        const anchorOffset = getElementOffsetTop(lastAnchor, viewport)\n        const contentHeight = measureContentHeight({ content, spacer, viewport })\n        applied = contentHeight - anchorOffset <= viewport.clientHeight\n          ? scrollToEnd({ behavior: \"auto\" })\n          : scrollToElement(lastAnchor, { align: \"start\" }, { keepPreviousPeek: true })\n      }\n    }\n    else {\n      applied = position === \"end\"\n        ? scrollToEnd({ behavior: \"auto\" })\n        : scrollToStart({ behavior: \"auto\" })\n    }\n    if (applied) {\n      defaultScrollPositionApplied = true\n      return true\n    }\n    return false\n  }\n\n  // --- content / resize handling ---------------------------------------------\n\n  function applyContentChange(\n    children: HTMLElement[],\n    previousCount: number,\n    previousFirst: HTMLElement | null,\n  ) {\n    if (flushPendingScrollToMessage())\n      return\n    if (previousCount === 0) {\n      if (\n        applyDefaultScrollPosition()\n        || (children.length > 0 && autoScroll() && scrollToEnd({ behavior: \"auto\" }))\n      ) {\n        return\n      }\n      commitScrollState()\n      scheduleVisibilitySync()\n      return\n    }\n    const previousIndex = previousFirst ? children.indexOf(previousFirst) : -1\n    if (preserveScrollOnPrepend && previousIndex > 0) {\n      applyPrependRestore()\n      return\n    }\n    if (children.length > previousCount) {\n      const anchor = findFirstAnchorFrom(children, previousCount)\n      if (anchor) {\n        if (\n          autoScroll()\n          && mode === \"following-bottom\"\n          && hasMultipleAnchorsFrom(children, previousCount)\n        ) {\n          scrollToEnd({ behavior: \"auto\" })\n          return\n        }\n        scrollToElement(anchor, { align: \"start\" }, { keepPreviousPeek: true })\n        handledScrollAnchors.add(anchor)\n        return\n      }\n    }\n    if (children.length === previousCount) {\n      const anchor = findFirstUnhandledAnchor(children, handledScrollAnchors)\n      if (anchor) {\n        scrollToElement(anchor, { align: \"start\" }, { keepPreviousPeek: true })\n        handledScrollAnchors.add(anchor)\n        return\n      }\n    }\n    if (mode === \"following-bottom\" && autoScroll()) {\n      scrollToEnd({ behavior: \"auto\" })\n    }\n    else {\n      commitScrollState()\n      scheduleVisibilitySync()\n    }\n  }\n\n  function handleContentChange() {\n    if (!content)\n      return\n    const children = getMessageChildren(content, spacer)\n    const previousCount = itemCount\n    const previousFirst = firstItem\n    itemCount = children.length\n    firstItem = children[0] ?? null\n\n    applyContentChange(children, previousCount, previousFirst)\n    capturePrependAnchor()\n  }\n\n  function handleResize() {\n    if (mode === \"following-bottom\" && autoScroll()) {\n      scrollToEnd({ behavior: \"auto\" })\n      return\n    }\n    const previousSpacerHeight = spacerHeight\n    if (reanchorToAnchoredMessage()) {\n      // The reply streaming below the anchor consumes the tail spacer as it\n      // grows. Once the last of it is gone the reply has filled the viewport\n      // and the reader is genuinely at the live edge, so autoScroll hands off\n      // from the anchor hold to following the bottom. Requiring the >0 → 0\n      // transition keeps a turn taller than the viewport (placed with no\n      // spacer) held instead of yanked to the end.\n      if (autoScroll() && previousSpacerHeight > 0 && spacerHeight === 0)\n        scrollToEnd({ behavior: \"auto\" })\n      return\n    }\n    scheduleStateCommit()\n    scheduleVisibilitySync()\n  }\n\n  // --- visibility observation ------------------------------------------------\n\n  function observeVisibility() {\n    if (!viewport || visibilityConsumers === 0)\n      return\n    if (typeof IntersectionObserver === \"undefined\") {\n      scheduleVisibilitySync()\n      return\n    }\n    if (!visibilityObserver) {\n      visibilityObserver = new IntersectionObserver(\n        (entries) => {\n          for (const entry of entries) {\n            const messageId = (entry.target as HTMLElement).dataset.messageId\n            if (!messageId)\n              continue\n            if (entry.isIntersecting)\n              visibleMessageIds.add(messageId)\n            else\n              visibleMessageIds.delete(messageId)\n          }\n          scheduleVisibilitySync()\n        },\n        {\n          root: viewport,\n          rootMargin: `${-(scrollMargin() + scrollPreviousItemPeek())}px 0px 0px 0px`,\n          threshold: [0, 0.01, 0.5, 1],\n        },\n      )\n    }\n    messageElements.forEach((element) => {\n      visibilityObserver?.observe(element)\n    })\n    scheduleVisibilitySync()\n  }\n\n  function unobserveVisibility() {\n    if (visibilityFrame !== null) {\n      window.cancelAnimationFrame(visibilityFrame)\n      visibilityFrame = null\n    }\n    visibilityObserver?.disconnect()\n    visibilityObserver = null\n    visibleMessageIds.clear()\n    if (!visibilityEqual(visibility.value, EMPTY_VISIBILITY))\n      visibility.value = EMPTY_VISIBILITY\n  }\n\n  function acquireVisibility() {\n    visibilityConsumers += 1\n    if (visibilityConsumers === 1)\n      observeVisibility()\n  }\n\n  function releaseVisibility() {\n    visibilityConsumers -= 1\n    if (visibilityConsumers === 0)\n      unobserveVisibility()\n  }\n\n  const registerMessage: RegisterMessage = (messageId, element, previousElement) => {\n    if (element) {\n      messageElements.set(messageId, element)\n      visibilityObserver?.observe(element)\n      scheduleVisibilitySync()\n      if (pendingScrollToMessage?.messageId === messageId)\n        schedulePrependFlush()\n      return\n    }\n    if (previousElement && messageElements.get(messageId) === previousElement) {\n      messageElements.delete(messageId)\n      visibleMessageIds.delete(messageId)\n      visibilityObserver?.unobserve(previousElement)\n      scheduleVisibilitySync()\n    }\n  }\n\n  // --- user intent + element setters -----------------------------------------\n\n  function userScrollIntent() {\n    if (\n      mode === \"following-bottom\"\n      || mode === \"anchored-to-message\"\n      || mode === \"settling-jump\"\n    ) {\n      streamingTurn = null\n      mode = \"free-scrolling\"\n    }\n  }\n\n  function setViewportElement(element: HTMLElement | null) {\n    viewport = element\n    // A visibility consumer may have subscribed before the viewport mounted,\n    // in which case observeVisibility() bailed out. Retry now it exists.\n    if (element)\n      observeVisibility()\n  }\n\n  function setContentElement(element: HTMLElement | null) {\n    content = element\n  }\n\n  function setSpacerElement(element: HTMLElement | null) {\n    spacer = element\n    spacerGap = getRowGap(element?.parentElement ?? null)\n  }\n\n  function setPreserveScrollOnPrepend(value: boolean) {\n    preserveScrollOnPrepend = value\n  }\n\n  function syncAfterScroll() {\n    commitScrollState()\n    scheduleVisibilitySync()\n    capturePrependAnchor()\n  }\n\n  function onAutoScrollChange() {\n    if (autoScroll() && mode === \"following-bottom\" && itemCount > 0) {\n      scrollToEnd({ behavior: \"auto\" })\n      return\n    }\n    commitScrollState()\n  }\n\n  function destroy() {\n    if (stateFrame !== null) {\n      window.cancelAnimationFrame(stateFrame)\n      stateFrame = null\n    }\n    if (visibilityFrame !== null) {\n      window.cancelAnimationFrame(visibilityFrame)\n      visibilityFrame = null\n    }\n    if (autoscrollingTimeout !== null) {\n      window.clearTimeout(autoscrollingTimeout)\n      autoscrollingTimeout = null\n    }\n    if (pendingScrollFrame !== null) {\n      window.cancelAnimationFrame(pendingScrollFrame)\n      pendingScrollFrame = null\n    }\n    visibilityObserver?.disconnect()\n    visibilityObserver = null\n  }\n\n  const context: MessageScrollerContext = {\n    autoscrolling,\n    scrollable,\n    scrollableAttr,\n    visibility,\n    acquireVisibility,\n    releaseVisibility,\n    handleContentChange,\n    handleResize,\n    scrollToEnd,\n    scrollToMessage,\n    scrollToStart,\n    setContentElement,\n    setSpacerElement,\n    setViewportElement,\n    setPreserveScrollOnPrepend,\n    syncAfterScroll,\n    userScrollIntent,\n  }\n\n  return {\n    context,\n    registerMessage,\n    applyDefaultScrollPosition,\n    onAutoScrollChange,\n    destroy,\n  }\n}\n\n// -----------------------------------------------------------------------------\n// Provider wiring (provide/inject)\n// -----------------------------------------------------------------------------\n\nexport function provideMessageScroller(props: MessageScrollerProviderProps) {\n  const engine = createEngine(props)\n  provide(CONTEXT_KEY, engine.context)\n  provide(REGISTER_KEY, engine.registerMessage)\n\n  watch(() => props.autoScroll ?? false, () => engine.onAutoScrollChange())\n\n  onMounted(() => {\n    engine.applyDefaultScrollPosition()\n    // The viewport element attaches in MessageScrollerViewport's onMounted,\n    // after MessageScrollerContent ran its initial handleContentChange without\n    // it. Re-sync now that every element is wired up.\n    engine.context.syncAfterScroll()\n  })\n\n  onScopeDispose(() => engine.destroy())\n\n  return engine\n}\n\nexport function useMessageScrollerContext(): MessageScrollerContext {\n  const context = inject(CONTEXT_KEY, null)\n  if (!context)\n    throw new Error(\"useMessageScroller must be used within a MessageScroller.\")\n  return context\n}\n\nexport function useMessageScrollerRegister(): RegisterMessage {\n  const register = inject(REGISTER_KEY, null)\n  if (!register)\n    throw new Error(\"MessageScrollerItem must be used within a MessageScroller.\")\n  return register\n}\n\n// -----------------------------------------------------------------------------\n// Public composables\n// -----------------------------------------------------------------------------\n\nexport function useMessageScroller() {\n  const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScrollerContext()\n  return { scrollToEnd, scrollToMessage, scrollToStart }\n}\n\nexport function useMessageScrollerScrollable(): Ref<MessageScrollerScrollable> {\n  const { scrollable } = useMessageScrollerContext()\n  return computed(() => scrollable.value)\n}\n\nexport function useMessageScrollerVisibility(): Ref<MessageScrollerVisibilityState> {\n  const { acquireVisibility, releaseVisibility, visibility } = useMessageScrollerContext()\n  acquireVisibility()\n  if (getCurrentScope())\n    onScopeDispose(releaseVisibility)\n  return computed(() => visibility.value)\n}\n\nexport { SCROLL_KEYS }\n"
    }
  ]
}
