Message Scroller
The chat viewport: anchors each turn, follows streaming replies and offers Jump to latest.
import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Marker, MarkerContent } from "@/components/ui/marker";import { Message, MessageContent } from "@/components/ui/message";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller";
const turns = Array.from({ length: 12 }, (_, i) => i);
export default function Demo() { return ( <div className="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3"> <MessageScrollerProvider autoScroll> <MessageScroller> <MessageScrollerViewport> <MessageScrollerContent className="gap-4 pr-2"> <Marker variant="separator"> <MarkerContent>Yesterday</MarkerContent> </Marker> {turns.map((i) => ( <MessageScrollerItem key={i} messageId={`m${i}`}> <Message align={i % 2 ? "end" : "start"}> <MessageContent> <Bubble align={i % 2 ? "end" : "start"} variant={i % 2 ? "default" : "secondary"} > <BubbleContent> {i % 2 ? "Any drift?" : `NVDAx is at ${30 + i}%.`} </BubbleContent> </Bubble> </MessageContent> </Message> </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </div> );}<script setup lang="ts">import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Marker, MarkerContent } from "@/components/ui/marker";import { Message, MessageContent } from "@/components/ui/message";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller";
const turns = Array.from({ length: 12 }, (_, i) => i);</script>
<template> <div class="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3"> <MessageScrollerProvider auto-scroll> <MessageScroller> <MessageScrollerViewport> <MessageScrollerContent class="gap-4 pr-2"> <Marker variant="separator"> <MarkerContent>Yesterday</MarkerContent> </Marker> <MessageScrollerItem v-for="i in turns" :key="i" :message-id="`m${i}`"> <Message :align="i % 2 ? 'end' : 'start'"> <MessageContent> <Bubble :align="i % 2 ? 'end' : 'start'" :variant="i % 2 ? 'default' : 'secondary'"> <BubbleContent>{{ i % 2 ? "Any drift?" : `NVDAx is at ${30 + i}%.` }}</BubbleContent> </Bubble> </MessageContent> </Message> </MessageScrollerItem> </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </div></template><script lang="ts"> import * as Bubble from "$lib/components/ui/bubble"; import * as Marker from "$lib/components/ui/marker"; import * as Message from "$lib/components/ui/message"; import * as MessageScroller from "$lib/components/ui/message-scroller";
const turns = Array.from({ length: 12 }, (_, i) => i);</script>
<div class="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3"> <MessageScroller.Provider autoScroll> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content class="gap-4 pr-2"> <Marker.Root variant="separator"> <Marker.Content>Yesterday</Marker.Content> </Marker.Root> {#each turns as i (i)} <MessageScroller.Item messageId={`m${i}`}> <Message.Root align={i % 2 ? "end" : "start"}> <Message.Content> <Bubble.Root align={i % 2 ? "end" : "start"} variant={i % 2 ? "default" : "secondary"} > <Bubble.Content> {i % 2 ? "Any drift?" : `NVDAx is at ${30 + i}%.`} </Bubble.Content> </Bubble.Root> </Message.Content> </Message.Root> </MessageScroller.Item> {/each} </MessageScroller.Content> </MessageScroller.Viewport> <MessageScroller.Button /> </MessageScroller.Root> </MessageScroller.Provider></div>Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/message-scrollerpnpm dlx shadcn@latest add @edmi-ui/message-scrolleryarn dlx shadcn@latest add @edmi-ui/message-scrollerbunx shadcn@latest add @edmi-ui/message-scrollerPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @shadcn/reactTerminal window pnpm add @shadcn/reactTerminal window yarn add @shadcn/reactTerminal window bun add @shadcn/reactAdd the Edmi components this one builds on:
button.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/message-scrollerpnpm dlx shadcn-vue@latest add @edmi-ui/message-scrolleryarn dlx shadcn-vue@latest add @edmi-ui/message-scrollerbunx shadcn-vue@latest add @edmi-ui/message-scrollerPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Marker, MarkerContent } from "@/components/ui/marker";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller";
<div className="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3"> <MessageScrollerProvider autoScroll> <MessageScroller> <MessageScrollerViewport> <MessageScrollerContent className="gap-4 pr-2"> <Marker variant="separator"> <MarkerContent>Yesterday</MarkerContent> </Marker> </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider></div><script setup lang="ts">import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Marker, MarkerContent } from "@/components/ui/marker";import { Message, MessageContent } from "@/components/ui/message";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller";</script>
<template> <div class="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3"> <MessageScrollerProvider auto-scroll> <MessageScroller> <MessageScrollerViewport> <MessageScrollerContent class="gap-4 pr-2"> <Marker variant="separator"> <MarkerContent>Yesterday</MarkerContent> </Marker> <MessageScrollerItem :key="i" :message-id="`m${i}`"> <Message :align="i % 2 ? 'end' : 'start'"> <MessageContent> <Bubble :align="i % 2 ? 'end' : 'start'" :variant="i % 2 ? 'default' : 'secondary'"> <BubbleContent>{{ i % 2 ? "Any drift?" : `NVDAx is at ${30 + i}%.` }}</BubbleContent> </Bubble> </MessageContent> </Message> </MessageScrollerItem> </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </div></template><script lang="ts"> import * as Marker from "$lib/components/ui/marker"; import * as MessageScroller from "$lib/components/ui/message-scroller";</script>
<div class="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3"> <MessageScroller.Provider autoScroll> <MessageScroller.Root> <MessageScroller.Viewport> <MessageScroller.Content class="gap-4 pr-2"> <Marker.Root variant="separator"> <Marker.Content>Yesterday</Marker.Content> </Marker.Root> <!-- … --> </MessageScroller.Content> </MessageScroller.Viewport> <MessageScroller.Button /> </MessageScroller.Root> </MessageScroller.Provider></div>Component source
Section titled “Component source”"use client";
import { MessageScroller as MessageScrollerPrimitive, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility,} from "@shadcn/react/message-scroller";import { cn } from "cn";import type * as React from "react";import { ArrowDownIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
function MessageScrollerProvider( props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,) { return <MessageScrollerPrimitive.Provider {...props} />;}
function MessageScroller({ className, ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) { return ( <MessageScrollerPrimitive.Root data-slot="message-scroller" className={cn( "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className, )} {...props} /> );}
function MessageScrollerViewport({ className, ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) { return ( <MessageScrollerPrimitive.Viewport data-slot="message-scroller-viewport" className={cn( "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 data-[pending-scroll]:invisible", className, )} {...props} /> );}
function MessageScrollerContent({ className, ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) { return ( <MessageScrollerPrimitive.Content data-slot="message-scroller-content" className={cn("flex h-max min-h-full flex-col gap-6", className)} {...props} /> );}
function MessageScrollerItem({ className, scrollAnchor = false, ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) { return ( <MessageScrollerPrimitive.Item data-slot="message-scroller-item" scrollAnchor={scrollAnchor} className={cn( "min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]", className, )} {...props} /> );}
function MessageScrollerButton({ direction = "end", className, children, render, variant = "outline", size = "sm", ...props}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { return ( <MessageScrollerPrimitive.Button data-slot="message-scroller-button" data-direction={direction} data-variant={variant} data-size={size} direction={direction} className={cn( "absolute left-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 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 data-[direction=start]:[&_svg]:rotate-180", className, )} render={render ?? <Button variant={variant} size={size} />} {...props} > {children ?? ( <> <ArrowDownIcon /> {/* ✦ Edmi: labelled pill, "Jump to latest" */} {direction === "end" ? "Jump to latest" : "Jump to start"} </> )} </MessageScrollerPrimitive.Button> );}
export { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility,};<script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useMessageScrollerContext } from "./useMessageScroller"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()
const { autoscrolling, scrollableAttr } = useMessageScrollerContext()</script>
<template> <div data-slot="message-scroller" :data-scrollable="scrollableAttr" :data-autoscrolling="autoscrolling ? '' : undefined" :class="cn( 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden', props.class, )" > <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { MessageScrollerButtonDirection } from "./useMessageScroller"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { ArrowDownIcon } from "@lucide/vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"import { useMessageScroller, useMessageScrollerScrollable } from "./useMessageScroller"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] direction?: MessageScrollerButtonDirection behavior?: ScrollBehavior variant?: ButtonVariants["variant"] size?: ButtonVariants["size"]}>(), { direction: "end", behavior: "smooth", variant: "outline", size: "sm",})
const { scrollToEnd, scrollToStart } = useMessageScroller()const scrollable = useMessageScrollerScrollable()
const active = computed(() => props.direction === "start" ? scrollable.value.start : scrollable.value.end)
function onClick(event: MouseEvent) { if (!active.value) return const target = event.currentTarget as HTMLElement | null target?.blur() if (event.defaultPrevented) return if (props.direction === "start") scrollToStart({ behavior: props.behavior }) else scrollToEnd({ behavior: props.behavior })}</script>
<template> <Button data-slot="message-scroller-button" :data-direction="direction" :data-active="active ? 'true' : 'false'" :variant="variant" :size="size" :inert="!active" :tabindex="active ? undefined : -1" :class="cn( 'absolute inset-s-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 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', props.class, )" @click="onClick" > <slot> <ArrowDownIcon /> <!-- ✦ Edmi: labelled pill, "Jump to latest" --> {{ direction === "end" ? "Jump to latest" : "Jump to start" }} </slot> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { onBeforeUnmount, onMounted, useTemplateRef } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useMessageScrollerContext } from "./useMessageScroller"
const props = defineProps<{ class?: HTMLAttributes["class"] spacerClass?: HTMLAttributes["class"]}>()
const { handleContentChange, handleResize, setContentElement, setSpacerElement,} = useMessageScrollerContext()
const contentRef = useTemplateRef<HTMLElement>("content")const spacerRef = useTemplateRef<HTMLElement>("spacer")
let mutationObserver: MutationObserver | null = nulllet resizeObserver: ResizeObserver | null = nulllet resizeFrame = 0
onMounted(() => { const content = contentRef.value if (!content) return
setContentElement(content) setSpacerElement(spacerRef.value ?? null) handleContentChange()
if (typeof MutationObserver !== "undefined") { mutationObserver = new MutationObserver(() => handleContentChange()) mutationObserver.observe(content, { childList: true }) }
if (typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(() => { window.cancelAnimationFrame(resizeFrame) resizeFrame = window.requestAnimationFrame(handleResize) }) resizeObserver.observe(content) }})
onBeforeUnmount(() => { window.cancelAnimationFrame(resizeFrame) mutationObserver?.disconnect() resizeObserver?.disconnect() mutationObserver = null resizeObserver = null setContentElement(null) setSpacerElement(null)})</script>
<template> <div ref="content" data-slot="message-scroller-content" role="log" aria-relevant="additions" :class="cn('flex h-max min-h-full flex-col gap-6', props.class)" > <slot /> <div ref="spacer" aria-hidden="true" data-message-scroller-spacer="" hidden :class="props.spacerClass" /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useMessageScrollerRegister } from "./useMessageScroller"
const props = withDefaults(defineProps<{ messageId?: string scrollAnchor?: boolean class?: HTMLAttributes["class"]}>(), { scrollAnchor: false,})
const register = useMessageScrollerRegister()
const itemEl = useTemplateRef<HTMLElement>("item")
onMounted(() => { if (props.messageId && itemEl.value) register(props.messageId, itemEl.value, null)})
watch(() => props.messageId, (messageId, previousMessageId) => { const element = itemEl.value if (!element) return if (previousMessageId) register(previousMessageId, null, element) if (messageId) register(messageId, element, null)})
onBeforeUnmount(() => { if (props.messageId && itemEl.value) register(props.messageId, null, itemEl.value)})</script>
<template> <div ref="item" data-slot="message-scroller-item" :data-message-id="messageId" :data-scroll-anchor="scrollAnchor ? 'true' : 'false'" :class="cn( 'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]', props.class, )" > <slot /> </div></template><script setup lang="ts">import type { MessageScrollerProviderProps } from "./useMessageScroller"import { provideMessageScroller } from "./useMessageScroller"
const props = defineProps<MessageScrollerProviderProps>()
provideMessageScroller(props)</script>
<template> <slot /></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { SCROLL_KEYS, useMessageScrollerContext } from "./useMessageScroller"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] preserveScrollOnPrepend?: boolean}>(), { preserveScrollOnPrepend: true,})
const { autoscrolling, handleResize, scrollableAttr, setPreserveScrollOnPrepend, setViewportElement, syncAfterScroll, userScrollIntent,} = useMessageScrollerContext()
const viewportEl = useTemplateRef<HTMLElement>("viewport")
watch(() => props.preserveScrollOnPrepend, setPreserveScrollOnPrepend, { immediate: true })
function onKeyDown(event: KeyboardEvent) { if (SCROLL_KEYS.has(event.key)) userScrollIntent()}
let resizeObserver: ResizeObserver | null = nulllet resizeFrame = 0
onMounted(() => { const viewport = viewportEl.value setViewportElement(viewport) if (!viewport || typeof ResizeObserver === "undefined") return resizeObserver = new ResizeObserver(() => { window.cancelAnimationFrame(resizeFrame) resizeFrame = window.requestAnimationFrame(handleResize) }) resizeObserver.observe(viewport)})
onBeforeUnmount(() => { window.cancelAnimationFrame(resizeFrame) resizeObserver?.disconnect() resizeObserver = null setViewportElement(null)})</script>
<template> <div ref="viewport" data-slot="message-scroller-viewport" role="region" aria-label="Messages" :tabindex="0" :data-scrollable="scrollableAttr" :data-autoscrolling="autoscrolling ? '' : undefined" :class="cn( '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', props.class, )" @scroll="syncAfterScroll()" @wheel="userScrollIntent()" @touchmove="userScrollIntent()" @keydown="onKeyDown" > <slot /> </div></template>import type { ComputedRef, InjectionKey, Ref, ShallowRef } from "vue";import { computed, getCurrentScope, inject, onMounted, onScopeDispose, provide, shallowRef, watch,} from "vue";
// -----------------------------------------------------------------------------// Public types// -----------------------------------------------------------------------------
export type MessageScrollerDefaultScrollPosition = | "start" | "end" | "last-anchor";export type MessageScrollerButtonDirection = "start" | "end";export type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest";
export interface MessageScrollerScrollOptions { align?: MessageScrollerScrollAlign; behavior?: ScrollBehavior; scrollMargin?: number;}
export interface MessageScrollerScrollable { start: boolean; end: boolean;}
export interface MessageScrollerVisibilityState { currentAnchorId: string | null; visibleMessageIds: string[];}
export interface MessageScrollerProviderProps { autoScroll?: boolean; defaultScrollPosition?: MessageScrollerDefaultScrollPosition; scrollEdgeThreshold?: number; scrollPreviousItemPeek?: number; scrollMargin?: number;}
// -----------------------------------------------------------------------------// Constants// -----------------------------------------------------------------------------
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8;const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64;const DEFAULT_SCROLL_MARGIN = 0;const SCROLL_EPSILON = 0.5;const AUTOSCROLLING_TIMEOUT = 180;
const SCROLL_KEYS = new Set([ "ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " ",]);
const EMPTY_SCROLLABLE: MessageScrollerScrollable = { start: false, end: false,};const EMPTY_VISIBLE_IDS: string[] = [];const EMPTY_VISIBILITY: MessageScrollerVisibilityState = { currentAnchorId: null, visibleMessageIds: EMPTY_VISIBLE_IDS,};
type Mode = | "following-bottom" | "free-scrolling" | "anchored-to-message" | "settling-jump";
interface PrependRestore { element: HTMLElement; viewportTop: number;}
interface PendingScrollToMessage { messageId: string; options?: MessageScrollerScrollOptions;}
function scrollableEqual( a: MessageScrollerScrollable, b: MessageScrollerScrollable,) { return a.start === b.start && a.end === b.end;}
function visibilityEqual( a: MessageScrollerVisibilityState, b: MessageScrollerVisibilityState,) { if ( a.currentAnchorId !== b.currentAnchorId || a.visibleMessageIds.length !== b.visibleMessageIds.length ) { return false; } return a.visibleMessageIds.every( (id, index) => id === b.visibleMessageIds[index], );}
// -----------------------------------------------------------------------------// DOM measurement helpers// -----------------------------------------------------------------------------
function parseNumber(value: string | null | undefined): number { if (!value) return 0; const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : 0;}
function getPadding(element: HTMLElement): { start: number; end: number } { const style = window.getComputedStyle(element); return { end: parseNumber(style.paddingBlockEnd || style.paddingBottom), start: parseNumber(style.paddingBlockStart || style.paddingTop), };}
function getContentPadding(spacer: HTMLElement | null): { start: number; end: number;} { const parent = spacer?.parentElement; return parent ? getPadding(parent) : { end: 0, start: 0 };}
function getRowGap(element: HTMLElement | null): number { if (!element) return 0; const style = window.getComputedStyle(element); const gap = style.rowGap === "normal" ? style.gap : style.rowGap; return parseNumber(gap);}
function getMessageChildren( content: HTMLElement, spacer: HTMLElement | null,): HTMLElement[] { return Array.from(content.children).filter( (child): child is HTMLElement => child instanceof HTMLElement && child !== spacer, );}
function getElementOffsetTop( element: HTMLElement, viewport: HTMLElement,): number { const elementRect = element.getBoundingClientRect(); const viewportRect = viewport.getBoundingClientRect(); return elementRect.top - viewportRect.top + viewport.scrollTop;}
function getRelativeTop(element: HTMLElement, viewport: HTMLElement): number { return ( element.getBoundingClientRect().top - viewport.getBoundingClientRect().top );}
function measureContentHeight({ content, spacer, viewport,}: { content: HTMLElement; spacer: HTMLElement | null; viewport: HTMLElement;}): number { const children = getMessageChildren(content, spacer); const padding = getPadding(content); const viewportRect = viewport.getBoundingClientRect(); const scrollTop = viewport.scrollTop; let height = padding.start + padding.end; for (const child of children) { const rect = child.getBoundingClientRect(); height = Math.max( height, rect.bottom - viewportRect.top + scrollTop + padding.end, ); } return height;}
function maxScrollTop(element: HTMLElement): number { return Math.max(0, element.scrollHeight - element.clientHeight);}
function computeSpacerHeight({ content, scrollTop, spacer, viewport,}: { content: HTMLElement; scrollTop: number; spacer: HTMLElement | null; viewport: HTMLElement;}): number { const contentHeight = measureContentHeight({ content, spacer, viewport }); return scrollTop + viewport.clientHeight - contentHeight;}
function computeScrollTopForElement({ align, element, scrollMargin, spacer, viewport,}: { align: MessageScrollerScrollAlign; element: HTMLElement; scrollMargin: number; spacer: HTMLElement | null; viewport: HTMLElement;}): number { const offsetTop = getElementOffsetTop(element, viewport); const height = element.getBoundingClientRect().height; const padding = getContentPadding(spacer);
if (align === "center") { const available = Math.max( 0, viewport.clientHeight - padding.start - padding.end, ); return offsetTop - padding.start - (available - height) / 2 - scrollMargin; } if (align === "end") return ( offsetTop - viewport.clientHeight + height + padding.end + scrollMargin ); if (align === "nearest") { const bottom = offsetTop + height; const visibleTop = viewport.scrollTop + padding.start; const visibleBottom = viewport.scrollTop + viewport.clientHeight - padding.end; if (offsetTop >= visibleTop && bottom <= visibleBottom) return viewport.scrollTop; return offsetTop < visibleTop ? offsetTop - padding.start - scrollMargin : bottom - viewport.clientHeight + padding.end + scrollMargin; } return offsetTop - padding.start - scrollMargin;}
function computeScrollable({ content, scrollEdgeThreshold, spacer, viewport,}: { content: HTMLElement | null; scrollEdgeThreshold: number; spacer: HTMLElement | null; viewport: HTMLElement | null;}): MessageScrollerScrollable { if (!viewport || !content) return EMPTY_SCROLLABLE; const contentHeight = measureContentHeight({ content, spacer, viewport }); return { start: viewport.scrollTop > scrollEdgeThreshold, end: contentHeight - viewport.scrollTop - viewport.clientHeight > scrollEdgeThreshold, };}
function computeVisibility({ content, scrollMargin, scrollPreviousItemPeek, spacer, viewport, visibleMessageIds,}: { content: HTMLElement | null; scrollMargin: number; scrollPreviousItemPeek: number; spacer: HTMLElement | null; viewport: HTMLElement | null; visibleMessageIds: Set<string>;}): MessageScrollerVisibilityState { if (!content || !viewport) return EMPTY_VISIBILITY; const viewportRect = viewport.getBoundingClientRect(); const anchorLine = viewportRect.top + scrollMargin + scrollPreviousItemPeek; const noIntersectionObserver = typeof IntersectionObserver === "undefined"; const visible: string[] = []; let currentAnchorId: string | null = null;
for (const child of getMessageChildren(content, spacer)) { const messageId = child.dataset.messageId; if (!messageId) continue; const isAnchor = child.dataset.scrollAnchor === "true"; const rect = isAnchor || noIntersectionObserver ? child.getBoundingClientRect() : null; const isVisible = noIntersectionObserver && rect ? rect.bottom > anchorLine && rect.top < viewportRect.bottom : visibleMessageIds.has(messageId); if (isVisible) visible.push(messageId); if (isAnchor && rect && rect.top <= anchorLine + SCROLL_EPSILON) currentAnchorId = messageId; }
return visible.length === 0 && currentAnchorId === null ? EMPTY_VISIBILITY : { currentAnchorId, visibleMessageIds: visible };}
function findFirstAnchorFrom( elements: HTMLElement[], startIndex: number,): HTMLElement | null { for (let i = startIndex; i < elements.length; i++) { const element = elements[i]; if (element?.dataset.scrollAnchor === "true") return element; } return null;}
function findFirstUnhandledAnchor( elements: HTMLElement[], handled: WeakSet<HTMLElement>,): HTMLElement | null { for (const element of elements) { if (element.dataset.scrollAnchor === "true" && !handled.has(element)) return element; } return null;}
function hasMultipleAnchorsFrom( elements: HTMLElement[], startIndex: number,): boolean { let count = 0; for (let i = startIndex; i < elements.length; i++) { if (elements[i]?.dataset.scrollAnchor === "true") { count += 1; if (count > 1) return true; } } return false;}
function findLastAnchor(elements: HTMLElement[]): HTMLElement | null { for (let i = elements.length - 1; i >= 0; i--) { const element = elements[i]; if (element?.dataset.scrollAnchor === "true") return element; } return null;}
function findFirstVisibleMessage({ content, spacer, viewport,}: { content: HTMLElement; spacer: HTMLElement | null; viewport: HTMLElement;}): HTMLElement | null { const viewportRect = viewport.getBoundingClientRect(); for (const child of getMessageChildren(content, spacer)) { if (!child.dataset.messageId) continue; const rect = child.getBoundingClientRect(); if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) return child; } return null;}
// -----------------------------------------------------------------------------// Engine// -----------------------------------------------------------------------------
export interface MessageScrollerContext { autoscrolling: Readonly<ShallowRef<boolean>>; scrollable: Readonly<ShallowRef<MessageScrollerScrollable>>; scrollableAttr: ComputedRef<string | undefined>; visibility: Readonly<ShallowRef<MessageScrollerVisibilityState>>; acquireVisibility: () => void; releaseVisibility: () => void; handleContentChange: () => void; handleResize: () => void; scrollToEnd: (options?: { behavior?: ScrollBehavior }) => boolean; scrollToMessage: ( messageId: string, options?: MessageScrollerScrollOptions, ) => boolean; scrollToStart: (options?: { behavior?: ScrollBehavior }) => boolean; setContentElement: (element: HTMLElement | null) => void; setSpacerElement: (element: HTMLElement | null) => void; setViewportElement: (element: HTMLElement | null) => void; setPreserveScrollOnPrepend: (value: boolean) => void; syncAfterScroll: () => void; userScrollIntent: () => void;}
export type RegisterMessage = ( messageId: string, element: HTMLElement | null, previousElement: HTMLElement | null,) => void;
const CONTEXT_KEY: InjectionKey<MessageScrollerContext> = Symbol( "MessageScrollerContext",);const REGISTER_KEY: InjectionKey<RegisterMessage> = Symbol( "MessageScrollerRegister",);
function createEngine(props: MessageScrollerProviderProps) { const autoScroll = () => props.autoScroll ?? false; const defaultScrollPosition = () => props.defaultScrollPosition ?? "end"; const scrollEdgeThreshold = () => props.scrollEdgeThreshold ?? DEFAULT_SCROLL_EDGE_THRESHOLD; const scrollPreviousItemPeek = () => props.scrollPreviousItemPeek ?? DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK; const scrollMargin = () => props.scrollMargin ?? DEFAULT_SCROLL_MARGIN;
let viewport: HTMLElement | null = null; let content: HTMLElement | null = null; let spacer: HTMLElement | null = null; let spacerGap = 0; let spacerHeight = 0; let mode: Mode = autoScroll() ? "following-bottom" : "free-scrolling"; let streamingTurn: HTMLElement | null = null; let firstItem: HTMLElement | null = null; let itemCount = 0; let lastScrollTop = 0; let defaultScrollPositionApplied = false; let preserveScrollOnPrepend = true; let prependRestore: PrependRestore | null = null; let pendingScrollToMessage: PendingScrollToMessage | null = null; let stateFrame: number | null = null; let visibilityFrame: number | null = null; let pendingScrollFrame: number | null = null; let autoscrollingTimeout: number | null = null; let visibilityObserver: IntersectionObserver | null = null; let visibilityConsumers = 0; const messageElements = new Map<string, HTMLElement>(); const visibleMessageIds = new Set<string>(); const handledScrollAnchors = new WeakSet<HTMLElement>();
const autoscrolling = shallowRef(false); const scrollable = shallowRef<MessageScrollerScrollable>(EMPTY_SCROLLABLE); const visibility = shallowRef<MessageScrollerVisibilityState>(EMPTY_VISIBILITY); const scrollableAttr = computed(() => { const attr = [ scrollable.value.start && "start", scrollable.value.end && "end", ] .filter(Boolean) .join(" "); return attr || undefined; });
// --- scroll-state commit / visibility scheduling ---------------------------
function updateModeFromScroll(next: MessageScrollerScrollable) { const scrollTop = viewport?.scrollTop ?? 0; const scrolledUp = scrollTop < lastScrollTop - SCROLL_EPSILON; lastScrollTop = scrollTop; if ( autoScroll() && !next.end && mode !== "settling-jump" && mode !== "anchored-to-message" ) { mode = "following-bottom"; } else if ( mode === "following-bottom" && next.end && scrolledUp && !autoscrolling.value ) { mode = "free-scrolling"; } }
function commitScrollState() { const measured = computeScrollable({ content, scrollEdgeThreshold: scrollEdgeThreshold(), spacer, viewport, }); updateModeFromScroll(measured); const next = mode === "following-bottom" ? { ...measured, end: false } : measured; if (!scrollableEqual(scrollable.value, next)) scrollable.value = next; }
function scheduleStateCommit() { if (stateFrame === null) { stateFrame = window.requestAnimationFrame(() => { stateFrame = null; commitScrollState(); }); } }
function scheduleVisibilitySync() { if (visibilityConsumers === 0) return; if (visibilityFrame === null) { visibilityFrame = window.requestAnimationFrame(() => { visibilityFrame = null; if (visibilityConsumers > 0) { const next = computeVisibility({ content, scrollMargin: scrollMargin(), scrollPreviousItemPeek: scrollPreviousItemPeek(), spacer, viewport, visibleMessageIds, }); if (!visibilityEqual(visibility.value, next)) visibility.value = next; } }); } }
// --- imperative scroll primitives ------------------------------------------
function setAutoscrolling(active: boolean) { if (autoscrollingTimeout !== null) { window.clearTimeout(autoscrollingTimeout); autoscrollingTimeout = null; } if (autoscrolling.value !== active) { autoscrolling.value = active; commitScrollState(); } if (active) { autoscrollingTimeout = window.setTimeout(() => { autoscrollingTimeout = null; autoscrolling.value = false; commitScrollState(); }, AUTOSCROLLING_TIMEOUT); } }
function setSpacerHeight(height: number) { if (!spacer) return; const next = Math.max(0, Math.ceil(height)); if (spacerHeight !== next) { spacerHeight = next; spacer.hidden = next === 0; spacer.style.height = `${next}px`; spacer.style.marginTop = next > 0 ? `${-spacerGap}px` : ""; } }
function scrollTo( top: number, { behavior = "auto", autoscrolling: isAutoscrolling = false, }: { behavior?: ScrollBehavior; autoscrolling?: boolean } = {}, ) { if (!viewport) return; const target = Math.max(0, top); if (Math.abs(viewport.scrollTop - target) <= SCROLL_EPSILON) { viewport.scrollTop = target; commitScrollState(); return; } if (isAutoscrolling) setAutoscrolling(true); viewport.scrollTo({ top: target, behavior }); scheduleStateCommit(); }
function scrollToStart({ behavior = "auto", }: { behavior?: ScrollBehavior; } = {}): boolean { if (!viewport) return false; setSpacerHeight(0); streamingTurn = null; mode = "free-scrolling"; scrollTo(0, { behavior }); scheduleVisibilitySync(); return true; }
function scrollToEnd({ behavior = "auto", }: { behavior?: ScrollBehavior; } = {}): boolean { if (!viewport) return false; setSpacerHeight(0); streamingTurn = null; mode = autoScroll() ? "following-bottom" : "free-scrolling"; scrollTo(maxScrollTop(viewport), { autoscrolling: true, behavior }); scheduleVisibilitySync(); return true; }
function scrollToElement( element: HTMLElement, { align = "start", behavior = "auto", scrollMargin: margin = scrollMargin(), }: MessageScrollerScrollOptions = {}, { keepPreviousPeek = false }: { keepPreviousPeek?: boolean } = {}, ): boolean { if (!content || !viewport || !content.contains(element)) return false; const targetScrollTop = computeScrollTopForElement({ align, element, scrollMargin: keepPreviousPeek ? margin + scrollPreviousItemPeek() : margin, spacer, viewport, }); setSpacerHeight( computeSpacerHeight({ content, scrollTop: targetScrollTop, spacer, viewport, }), ); prependRestore = { element, viewportTop: getRelativeTop(element, viewport), }; mode = keepPreviousPeek ? "anchored-to-message" : "settling-jump"; streamingTurn = keepPreviousPeek ? element : null; scrollTo(targetScrollTop, { behavior }); scheduleVisibilitySync(); return true; }
function reanchorToAnchoredMessage(): boolean { if (!streamingTurn?.isConnected || mode !== "anchored-to-message") return false; return scrollToElement( streamingTurn, { align: "start" }, { keepPreviousPeek: true }, ); }
function scrollToMessage( messageId: string, options?: MessageScrollerScrollOptions, ): boolean { const element = messageElements.get(messageId); if (element) { defaultScrollPositionApplied = true; if (scrollToElement(element, options)) { pendingScrollToMessage = null; return true; } pendingScrollToMessage = { messageId, options }; return true; } if (itemCount === 0) { pendingScrollToMessage = { messageId, options }; defaultScrollPositionApplied = true; return true; } return false; }
function flushPendingScrollToMessage(): boolean { if (!pendingScrollToMessage) return false; const element = messageElements.get(pendingScrollToMessage.messageId); if (!element || !scrollToElement(element, pendingScrollToMessage.options)) return false; pendingScrollToMessage = null; defaultScrollPositionApplied = true; return true; }
// --- prepend preservation --------------------------------------------------
function applyPrependRestore(): boolean { if (!prependRestore || !viewport || !prependRestore.element.isConnected) return false; const delta = getRelativeTop(prependRestore.element, viewport) - prependRestore.viewportTop; if (Math.abs(delta) <= SCROLL_EPSILON) return false; viewport.scrollTop += delta; prependRestore.viewportTop = getRelativeTop( prependRestore.element, viewport, ); scheduleStateCommit(); scheduleVisibilitySync(); return true; }
function capturePrependAnchor() { if (!content || !viewport) { prependRestore = null; return; } const element = findFirstVisibleMessage({ content, spacer, viewport }); prependRestore = element ? { element, viewportTop: getRelativeTop(element, viewport) } : null; }
function schedulePrependFlush() { if (pendingScrollFrame === null) { pendingScrollFrame = window.requestAnimationFrame(() => { pendingScrollFrame = null; if (flushPendingScrollToMessage()) capturePrependAnchor(); }); } }
// --- default scroll position -----------------------------------------------
function applyDefaultScrollPosition(): boolean { if (defaultScrollPositionApplied || itemCount === 0) return false; const position = defaultScrollPosition(); let applied = false; if (position === "last-anchor") { const lastAnchor = content && viewport ? findLastAnchor(getMessageChildren(content, spacer)) : null; if (!content || !viewport || !lastAnchor) { applied = scrollToEnd({ behavior: "auto" }); } else { const anchorOffset = getElementOffsetTop(lastAnchor, viewport); const contentHeight = measureContentHeight({ content, spacer, viewport, }); applied = contentHeight - anchorOffset <= viewport.clientHeight ? scrollToEnd({ behavior: "auto" }) : scrollToElement( lastAnchor, { align: "start" }, { keepPreviousPeek: true }, ); } } else { applied = position === "end" ? scrollToEnd({ behavior: "auto" }) : scrollToStart({ behavior: "auto" }); } if (applied) { defaultScrollPositionApplied = true; return true; } return false; }
// --- content / resize handling ---------------------------------------------
function applyContentChange( children: HTMLElement[], previousCount: number, previousFirst: HTMLElement | null, ) { if (flushPendingScrollToMessage()) return; if (previousCount === 0) { if ( applyDefaultScrollPosition() || (children.length > 0 && autoScroll() && scrollToEnd({ behavior: "auto" })) ) { return; } commitScrollState(); scheduleVisibilitySync(); return; } const previousIndex = previousFirst ? children.indexOf(previousFirst) : -1; if (preserveScrollOnPrepend && previousIndex > 0) { applyPrependRestore(); return; } if (children.length > previousCount) { const anchor = findFirstAnchorFrom(children, previousCount); if (anchor) { if ( autoScroll() && mode === "following-bottom" && hasMultipleAnchorsFrom(children, previousCount) ) { scrollToEnd({ behavior: "auto" }); return; } scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true }); handledScrollAnchors.add(anchor); return; } } if (children.length === previousCount) { const anchor = findFirstUnhandledAnchor(children, handledScrollAnchors); if (anchor) { scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true }); handledScrollAnchors.add(anchor); return; } } if (mode === "following-bottom" && autoScroll()) { scrollToEnd({ behavior: "auto" }); } else { commitScrollState(); scheduleVisibilitySync(); } }
function handleContentChange() { if (!content) return; const children = getMessageChildren(content, spacer); const previousCount = itemCount; const previousFirst = firstItem; itemCount = children.length; firstItem = children[0] ?? null;
applyContentChange(children, previousCount, previousFirst); capturePrependAnchor(); }
function handleResize() { if (mode === "following-bottom" && autoScroll()) { scrollToEnd({ behavior: "auto" }); return; } const previousSpacerHeight = spacerHeight; if (reanchorToAnchoredMessage()) { // The reply streaming below the anchor consumes the tail spacer as it // grows. Once the last of it is gone the reply has filled the viewport // and the reader is genuinely at the live edge, so autoScroll hands off // from the anchor hold to following the bottom. Requiring the >0 → 0 // transition keeps a turn taller than the viewport (placed with no // spacer) held instead of yanked to the end. if (autoScroll() && previousSpacerHeight > 0 && spacerHeight === 0) scrollToEnd({ behavior: "auto" }); return; } scheduleStateCommit(); scheduleVisibilitySync(); }
// --- visibility observation ------------------------------------------------
function observeVisibility() { if (!viewport || visibilityConsumers === 0) return; if (typeof IntersectionObserver === "undefined") { scheduleVisibilitySync(); return; } if (!visibilityObserver) { visibilityObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const messageId = (entry.target as HTMLElement).dataset.messageId; if (!messageId) continue; if (entry.isIntersecting) visibleMessageIds.add(messageId); else visibleMessageIds.delete(messageId); } scheduleVisibilitySync(); }, { root: viewport, rootMargin: `${-(scrollMargin() + scrollPreviousItemPeek())}px 0px 0px 0px`, threshold: [0, 0.01, 0.5, 1], }, ); } messageElements.forEach((element) => { visibilityObserver?.observe(element); }); scheduleVisibilitySync(); }
function unobserveVisibility() { if (visibilityFrame !== null) { window.cancelAnimationFrame(visibilityFrame); visibilityFrame = null; } visibilityObserver?.disconnect(); visibilityObserver = null; visibleMessageIds.clear(); if (!visibilityEqual(visibility.value, EMPTY_VISIBILITY)) visibility.value = EMPTY_VISIBILITY; }
function acquireVisibility() { visibilityConsumers += 1; if (visibilityConsumers === 1) observeVisibility(); }
function releaseVisibility() { visibilityConsumers -= 1; if (visibilityConsumers === 0) unobserveVisibility(); }
const registerMessage: RegisterMessage = ( messageId, element, previousElement, ) => { if (element) { messageElements.set(messageId, element); visibilityObserver?.observe(element); scheduleVisibilitySync(); if (pendingScrollToMessage?.messageId === messageId) schedulePrependFlush(); return; } if (previousElement && messageElements.get(messageId) === previousElement) { messageElements.delete(messageId); visibleMessageIds.delete(messageId); visibilityObserver?.unobserve(previousElement); scheduleVisibilitySync(); } };
// --- user intent + element setters -----------------------------------------
function userScrollIntent() { if ( mode === "following-bottom" || mode === "anchored-to-message" || mode === "settling-jump" ) { streamingTurn = null; mode = "free-scrolling"; } }
function setViewportElement(element: HTMLElement | null) { viewport = element; // A visibility consumer may have subscribed before the viewport mounted, // in which case observeVisibility() bailed out. Retry now it exists. if (element) observeVisibility(); }
function setContentElement(element: HTMLElement | null) { content = element; }
function setSpacerElement(element: HTMLElement | null) { spacer = element; spacerGap = getRowGap(element?.parentElement ?? null); }
function setPreserveScrollOnPrepend(value: boolean) { preserveScrollOnPrepend = value; }
function syncAfterScroll() { commitScrollState(); scheduleVisibilitySync(); capturePrependAnchor(); }
function onAutoScrollChange() { if (autoScroll() && mode === "following-bottom" && itemCount > 0) { scrollToEnd({ behavior: "auto" }); return; } commitScrollState(); }
function destroy() { if (stateFrame !== null) { window.cancelAnimationFrame(stateFrame); stateFrame = null; } if (visibilityFrame !== null) { window.cancelAnimationFrame(visibilityFrame); visibilityFrame = null; } if (autoscrollingTimeout !== null) { window.clearTimeout(autoscrollingTimeout); autoscrollingTimeout = null; } if (pendingScrollFrame !== null) { window.cancelAnimationFrame(pendingScrollFrame); pendingScrollFrame = null; } visibilityObserver?.disconnect(); visibilityObserver = null; }
const context: MessageScrollerContext = { autoscrolling, scrollable, scrollableAttr, visibility, acquireVisibility, releaseVisibility, handleContentChange, handleResize, scrollToEnd, scrollToMessage, scrollToStart, setContentElement, setSpacerElement, setViewportElement, setPreserveScrollOnPrepend, syncAfterScroll, userScrollIntent, };
return { context, registerMessage, applyDefaultScrollPosition, onAutoScrollChange, destroy, };}
// -----------------------------------------------------------------------------// Provider wiring (provide/inject)// -----------------------------------------------------------------------------
export function provideMessageScroller(props: MessageScrollerProviderProps) { const engine = createEngine(props); provide(CONTEXT_KEY, engine.context); provide(REGISTER_KEY, engine.registerMessage);
watch( () => props.autoScroll ?? false, () => engine.onAutoScrollChange(), );
onMounted(() => { engine.applyDefaultScrollPosition(); // The viewport element attaches in MessageScrollerViewport's onMounted, // after MessageScrollerContent ran its initial handleContentChange without // it. Re-sync now that every element is wired up. engine.context.syncAfterScroll(); });
onScopeDispose(() => engine.destroy());
return engine;}
export function useMessageScrollerContext(): MessageScrollerContext { const context = inject(CONTEXT_KEY, null); if (!context) throw new Error( "useMessageScroller must be used within a MessageScroller.", ); return context;}
export function useMessageScrollerRegister(): RegisterMessage { const register = inject(REGISTER_KEY, null); if (!register) throw new Error( "MessageScrollerItem must be used within a MessageScroller.", ); return register;}
// -----------------------------------------------------------------------------// Public composables// -----------------------------------------------------------------------------
export function useMessageScroller() { const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScrollerContext(); return { scrollToEnd, scrollToMessage, scrollToStart };}
export function useMessageScrollerScrollable(): Ref<MessageScrollerScrollable> { const { scrollable } = useMessageScrollerContext(); return computed(() => scrollable.value);}
export function useMessageScrollerVisibility(): Ref<MessageScrollerVisibilityState> { const { acquireVisibility, releaseVisibility, visibility } = useMessageScrollerContext(); acquireVisibility(); if (getCurrentScope()) onScopeDispose(releaseVisibility); return computed(() => visibility.value);}
export { SCROLL_KEYS };export { default as MessageScroller } from "./MessageScroller.vue";export { default as MessageScrollerButton } from "./MessageScrollerButton.vue";export { default as MessageScrollerContent } from "./MessageScrollerContent.vue";export { default as MessageScrollerItem } from "./MessageScrollerItem.vue";export { default as MessageScrollerProvider } from "./MessageScrollerProvider.vue";export { default as MessageScrollerViewport } from "./MessageScrollerViewport.vue";
export type { MessageScrollerButtonDirection, MessageScrollerDefaultScrollPosition, MessageScrollerProviderProps, MessageScrollerScrollAlign, MessageScrollerScrollable, MessageScrollerScrollOptions, MessageScrollerVisibilityState,} from "./useMessageScroller";
export { useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility,} from "./useMessageScroller";<script lang="ts"> import { cn } from "$lib/utils.js"; import ArrowDownIcon from "phosphor-svelte/lib/ArrowDown"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { type MessageScrollerButtonDirection, useMessageScrollerContext, } from "./use-message-scroller.svelte.js";
let { ref = $bindable(null), class: className, direction = "end", behavior = "smooth", variant = "outline", size = "sm", children, onclick, ...restProps }: Omit<ButtonProps, "href" | "onclick"> & { onclick?: (event: MouseEvent) => void; direction?: MessageScrollerButtonDirection; behavior?: ScrollBehavior; } = $props();
const scroller = useMessageScrollerContext(); const active = $derived(direction === "start" ? scroller.scrollable.start : scroller.scrollable.end);
function handleClick(event: MouseEvent) { if (!active) return; (event.currentTarget as HTMLElement | null)?.blur(); onclick?.(event); if (event.defaultPrevented) return; if (direction === "start") scroller.scrollToStart({ behavior }); else scroller.scrollToEnd({ behavior }); }</script>
<Button bind:ref data-slot="message-scroller-button" data-direction={direction} data-active={active ? "true" : "false"} {variant} {size} inert={!active} tabindex={active ? undefined : -1} class={cn( "absolute left-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 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 data-[direction=start]:[&_svg]:rotate-180", className )} onclick={handleClick} {...restProps}> {#if children} {@render children()} {:else} <ArrowDownIcon /> <!-- ✦ Edmi: labelled pill, "Jump to latest" --> {direction === "end" ? "Jump to latest" : "Jump to start"} {/if}</Button><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { onMount } from "svelte"; import { useMessageScrollerContext } from "./use-message-scroller.svelte.js";
let { ref = $bindable(null), class: className, spacerClass, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { spacerClass?: string } = $props();
const scroller = useMessageScrollerContext(); let spacer: HTMLDivElement | null = $state(null);
onMount(() => { const content = ref; if (!content) return; scroller.setContentElement(content); scroller.setSpacerElement(spacer); scroller.handleContentChange(); let frame = 0; let mutationObserver: MutationObserver | null = null; let resizeObserver: ResizeObserver | null = null; if (typeof MutationObserver !== "undefined") { mutationObserver = new MutationObserver(() => scroller.handleContentChange()); mutationObserver.observe(content, { childList: true }); } if (typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(() => { window.cancelAnimationFrame(frame); frame = window.requestAnimationFrame(scroller.handleResize); }); resizeObserver.observe(content); } return () => { window.cancelAnimationFrame(frame); mutationObserver?.disconnect(); resizeObserver?.disconnect(); scroller.setContentElement(null); scroller.setSpacerElement(null); }; });</script>
<div bind:this={ref} data-slot="message-scroller-content" role="log" aria-relevant="additions" class={cn("flex h-max min-h-full flex-col gap-6", className)} {...restProps}> {@render children?.()} <div bind:this={spacer} aria-hidden="true" data-message-scroller-spacer="" hidden class={spacerClass} ></div></div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { useMessageScrollerRegister } from "./use-message-scroller.svelte.js";
let { ref = $bindable(null), class: className, messageId = undefined, scrollAnchor = false, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { messageId?: string; scrollAnchor?: boolean; } = $props();
const register = useMessageScrollerRegister();
$effect(() => { const element = ref; const id = messageId; if (!id || !element) return; register(id, element, null); return () => register(id, null, element); });</script>
<div bind:this={ref} data-slot="message-scroller-item" data-message-id={messageId} data-scroll-anchor={scrollAnchor ? "true" : "false"} class={cn("min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import type { Snippet } from "svelte"; import { type MessageScrollerProviderProps, provideMessageScroller, } from "./use-message-scroller.svelte.js";
let { children, ...props }: MessageScrollerProviderProps & { children?: Snippet } = $props();
// `props` is a live proxy; the engine reads it lazily, so it stays reactive. // svelte-ignore state_referenced_locally provideMessageScroller(props);</script>
{@render children?.()}<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { onMount } from "svelte"; import { SCROLL_KEYS, useMessageScrollerContext } from "./use-message-scroller.svelte.js";
let { ref = $bindable(null), class: className, preserveScrollOnPrepend = true, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { preserveScrollOnPrepend?: boolean; } = $props();
const scroller = useMessageScrollerContext();
$effect(() => { scroller.setPreserveScrollOnPrepend(preserveScrollOnPrepend); });
onMount(() => { const viewport = ref; scroller.setViewportElement(viewport); let frame = 0; let observer: ResizeObserver | null = null; if (viewport && typeof ResizeObserver !== "undefined") { observer = new ResizeObserver(() => { window.cancelAnimationFrame(frame); frame = window.requestAnimationFrame(scroller.handleResize); }); observer.observe(viewport); } return () => { window.cancelAnimationFrame(frame); observer?.disconnect(); scroller.setViewportElement(null); }; });</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --><div bind:this={ref} data-slot="message-scroller-viewport" role="region" aria-label="Messages" tabindex="0" data-scrollable={scroller.scrollableAttr} data-autoscrolling={scroller.autoscrolling ? "" : undefined} class={cn( "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", className )} onscroll={() => scroller.syncAfterScroll()} onwheel={() => scroller.userScrollIntent()} ontouchmove={() => scroller.userScrollIntent()} onkeydown={(event) => { if (SCROLL_KEYS.has(event.key)) scroller.userScrollIntent(); }} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { useMessageScrollerContext } from "./use-message-scroller.svelte.js";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const scroller = useMessageScrollerContext();</script>
<div bind:this={ref} data-slot="message-scroller" data-scrollable={scroller.scrollableAttr} data-autoscrolling={scroller.autoscrolling ? "" : undefined} class={cn("group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className)} {...restProps}> {@render children?.()}</div>import { getContext, onDestroy, onMount, setContext } from "svelte";
// -----------------------------------------------------------------------------// Public types// -----------------------------------------------------------------------------
export type MessageScrollerDefaultScrollPosition = | "start" | "end" | "last-anchor";export type MessageScrollerButtonDirection = "start" | "end";export type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest";
export interface MessageScrollerScrollOptions { align?: MessageScrollerScrollAlign; behavior?: ScrollBehavior; scrollMargin?: number;}
export interface MessageScrollerScrollable { start: boolean; end: boolean;}
export interface MessageScrollerVisibilityState { currentAnchorId: string | null; visibleMessageIds: string[];}
export interface MessageScrollerProviderProps { autoScroll?: boolean; defaultScrollPosition?: MessageScrollerDefaultScrollPosition; scrollEdgeThreshold?: number; scrollPreviousItemPeek?: number; scrollMargin?: number;}
// -----------------------------------------------------------------------------// Constants// -----------------------------------------------------------------------------
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8;const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64;const DEFAULT_SCROLL_MARGIN = 0;const SCROLL_EPSILON = 0.5;const AUTOSCROLLING_TIMEOUT = 180;
const SCROLL_KEYS = new Set([ "ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " ",]);
const EMPTY_SCROLLABLE: MessageScrollerScrollable = { start: false, end: false,};const EMPTY_VISIBLE_IDS: string[] = [];const EMPTY_VISIBILITY: MessageScrollerVisibilityState = { currentAnchorId: null, visibleMessageIds: EMPTY_VISIBLE_IDS,};
type Mode = | "following-bottom" | "free-scrolling" | "anchored-to-message" | "settling-jump";
interface PrependRestore { element: HTMLElement; viewportTop: number;}
interface PendingScrollToMessage { messageId: string; options?: MessageScrollerScrollOptions;}
function scrollableEqual( a: MessageScrollerScrollable, b: MessageScrollerScrollable,) { return a.start === b.start && a.end === b.end;}
function visibilityEqual( a: MessageScrollerVisibilityState, b: MessageScrollerVisibilityState,) { if ( a.currentAnchorId !== b.currentAnchorId || a.visibleMessageIds.length !== b.visibleMessageIds.length ) { return false; } return a.visibleMessageIds.every( (id, index) => id === b.visibleMessageIds[index], );}
// -----------------------------------------------------------------------------// DOM measurement helpers// -----------------------------------------------------------------------------
function parseNumber(value: string | null | undefined): number { if (!value) return 0; const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : 0;}
function getPadding(element: HTMLElement): { start: number; end: number } { const style = window.getComputedStyle(element); return { end: parseNumber(style.paddingBlockEnd || style.paddingBottom), start: parseNumber(style.paddingBlockStart || style.paddingTop), };}
function getContentPadding(spacer: HTMLElement | null): { start: number; end: number;} { const parent = spacer?.parentElement; return parent ? getPadding(parent) : { end: 0, start: 0 };}
function getRowGap(element: HTMLElement | null): number { if (!element) return 0; const style = window.getComputedStyle(element); const gap = style.rowGap === "normal" ? style.gap : style.rowGap; return parseNumber(gap);}
function getMessageChildren( content: HTMLElement, spacer: HTMLElement | null,): HTMLElement[] { return Array.from(content.children).filter( (child): child is HTMLElement => child instanceof HTMLElement && child !== spacer, );}
function getElementOffsetTop( element: HTMLElement, viewport: HTMLElement,): number { const elementRect = element.getBoundingClientRect(); const viewportRect = viewport.getBoundingClientRect(); return elementRect.top - viewportRect.top + viewport.scrollTop;}
function getRelativeTop(element: HTMLElement, viewport: HTMLElement): number { return ( element.getBoundingClientRect().top - viewport.getBoundingClientRect().top );}
function measureContentHeight({ content, spacer, viewport,}: { content: HTMLElement; spacer: HTMLElement | null; viewport: HTMLElement;}): number { const children = getMessageChildren(content, spacer); const padding = getPadding(content); const viewportRect = viewport.getBoundingClientRect(); const scrollTop = viewport.scrollTop; let height = padding.start + padding.end; for (const child of children) { const rect = child.getBoundingClientRect(); height = Math.max( height, rect.bottom - viewportRect.top + scrollTop + padding.end, ); } return height;}
function maxScrollTop(element: HTMLElement): number { return Math.max(0, element.scrollHeight - element.clientHeight);}
function computeSpacerHeight({ content, scrollTop, spacer, viewport,}: { content: HTMLElement; scrollTop: number; spacer: HTMLElement | null; viewport: HTMLElement;}): number { const contentHeight = measureContentHeight({ content, spacer, viewport }); return scrollTop + viewport.clientHeight - contentHeight;}
function computeScrollTopForElement({ align, element, scrollMargin, spacer, viewport,}: { align: MessageScrollerScrollAlign; element: HTMLElement; scrollMargin: number; spacer: HTMLElement | null; viewport: HTMLElement;}): number { const offsetTop = getElementOffsetTop(element, viewport); const height = element.getBoundingClientRect().height; const padding = getContentPadding(spacer);
if (align === "center") { const available = Math.max( 0, viewport.clientHeight - padding.start - padding.end, ); return offsetTop - padding.start - (available - height) / 2 - scrollMargin; } if (align === "end") return ( offsetTop - viewport.clientHeight + height + padding.end + scrollMargin ); if (align === "nearest") { const bottom = offsetTop + height; const visibleTop = viewport.scrollTop + padding.start; const visibleBottom = viewport.scrollTop + viewport.clientHeight - padding.end; if (offsetTop >= visibleTop && bottom <= visibleBottom) return viewport.scrollTop; return offsetTop < visibleTop ? offsetTop - padding.start - scrollMargin : bottom - viewport.clientHeight + padding.end + scrollMargin; } return offsetTop - padding.start - scrollMargin;}
function computeScrollable({ content, scrollEdgeThreshold, spacer, viewport,}: { content: HTMLElement | null; scrollEdgeThreshold: number; spacer: HTMLElement | null; viewport: HTMLElement | null;}): MessageScrollerScrollable { if (!viewport || !content) return EMPTY_SCROLLABLE; const contentHeight = measureContentHeight({ content, spacer, viewport }); return { start: viewport.scrollTop > scrollEdgeThreshold, end: contentHeight - viewport.scrollTop - viewport.clientHeight > scrollEdgeThreshold, };}
function computeVisibility({ content, scrollMargin, scrollPreviousItemPeek, spacer, viewport, visibleMessageIds,}: { content: HTMLElement | null; scrollMargin: number; scrollPreviousItemPeek: number; spacer: HTMLElement | null; viewport: HTMLElement | null; visibleMessageIds: Set<string>;}): MessageScrollerVisibilityState { if (!content || !viewport) return EMPTY_VISIBILITY; const viewportRect = viewport.getBoundingClientRect(); const anchorLine = viewportRect.top + scrollMargin + scrollPreviousItemPeek; const noIntersectionObserver = typeof IntersectionObserver === "undefined"; const visible: string[] = []; let currentAnchorId: string | null = null;
for (const child of getMessageChildren(content, spacer)) { const messageId = child.dataset.messageId; if (!messageId) continue; const isAnchor = child.dataset.scrollAnchor === "true"; const rect = isAnchor || noIntersectionObserver ? child.getBoundingClientRect() : null; const isVisible = noIntersectionObserver && rect ? rect.bottom > anchorLine && rect.top < viewportRect.bottom : visibleMessageIds.has(messageId); if (isVisible) visible.push(messageId); if (isAnchor && rect && rect.top <= anchorLine + SCROLL_EPSILON) currentAnchorId = messageId; }
return visible.length === 0 && currentAnchorId === null ? EMPTY_VISIBILITY : { currentAnchorId, visibleMessageIds: visible };}
function findFirstAnchorFrom( elements: HTMLElement[], startIndex: number,): HTMLElement | null { for (let i = startIndex; i < elements.length; i++) { const element = elements[i]; if (element?.dataset.scrollAnchor === "true") return element; } return null;}
function findFirstUnhandledAnchor( elements: HTMLElement[], handled: WeakSet<HTMLElement>,): HTMLElement | null { for (const element of elements) { if (element.dataset.scrollAnchor === "true" && !handled.has(element)) return element; } return null;}
function hasMultipleAnchorsFrom( elements: HTMLElement[], startIndex: number,): boolean { let count = 0; for (let i = startIndex; i < elements.length; i++) { if (elements[i]?.dataset.scrollAnchor === "true") { count += 1; if (count > 1) return true; } } return false;}
function findLastAnchor(elements: HTMLElement[]): HTMLElement | null { for (let i = elements.length - 1; i >= 0; i--) { const element = elements[i]; if (element?.dataset.scrollAnchor === "true") return element; } return null;}
function findFirstVisibleMessage({ content, spacer, viewport,}: { content: HTMLElement; spacer: HTMLElement | null; viewport: HTMLElement;}): HTMLElement | null { const viewportRect = viewport.getBoundingClientRect(); for (const child of getMessageChildren(content, spacer)) { if (!child.dataset.messageId) continue; const rect = child.getBoundingClientRect(); if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) return child; } return null;}
// -----------------------------------------------------------------------------// Engine// -----------------------------------------------------------------------------
export interface MessageScrollerContext { readonly autoscrolling: boolean; readonly scrollable: MessageScrollerScrollable; readonly scrollableAttr: string | undefined; readonly visibility: MessageScrollerVisibilityState; acquireVisibility: () => void; releaseVisibility: () => void; handleContentChange: () => void; handleResize: () => void; scrollToEnd: (options?: { behavior?: ScrollBehavior }) => boolean; scrollToMessage: ( messageId: string, options?: MessageScrollerScrollOptions, ) => boolean; scrollToStart: (options?: { behavior?: ScrollBehavior }) => boolean; setContentElement: (element: HTMLElement | null) => void; setSpacerElement: (element: HTMLElement | null) => void; setViewportElement: (element: HTMLElement | null) => void; setPreserveScrollOnPrepend: (value: boolean) => void; syncAfterScroll: () => void; userScrollIntent: () => void;}
export type RegisterMessage = ( messageId: string, element: HTMLElement | null, previousElement: HTMLElement | null,) => void;
const CONTEXT_KEY = Symbol("MessageScrollerContext");const REGISTER_KEY = Symbol("MessageScrollerRegister");
function createEngine(props: MessageScrollerProviderProps) { const autoScroll = () => props.autoScroll ?? false; const defaultScrollPosition = () => props.defaultScrollPosition ?? "end"; const scrollEdgeThreshold = () => props.scrollEdgeThreshold ?? DEFAULT_SCROLL_EDGE_THRESHOLD; const scrollPreviousItemPeek = () => props.scrollPreviousItemPeek ?? DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK; const scrollMargin = () => props.scrollMargin ?? DEFAULT_SCROLL_MARGIN;
let viewport: HTMLElement | null = null; let content: HTMLElement | null = null; let spacer: HTMLElement | null = null; let spacerGap = 0; let spacerHeight = 0; let mode: Mode = autoScroll() ? "following-bottom" : "free-scrolling"; let streamingTurn: HTMLElement | null = null; let firstItem: HTMLElement | null = null; let itemCount = 0; let lastScrollTop = 0; let defaultScrollPositionApplied = false; let preserveScrollOnPrepend = true; let prependRestore: PrependRestore | null = null; let pendingScrollToMessage: PendingScrollToMessage | null = null; let stateFrame: number | null = null; let visibilityFrame: number | null = null; let pendingScrollFrame: number | null = null; let autoscrollingTimeout: number | null = null; let visibilityObserver: IntersectionObserver | null = null; let visibilityConsumers = 0; const messageElements = new Map<string, HTMLElement>(); const visibleMessageIds = new Set<string>(); const handledScrollAnchors = new WeakSet<HTMLElement>();
let autoscrolling = $state(false); let scrollable = $state.raw<MessageScrollerScrollable>(EMPTY_SCROLLABLE); let visibility = $state.raw<MessageScrollerVisibilityState>(EMPTY_VISIBILITY); const scrollableAttr = () => { const attr = [scrollable.start && "start", scrollable.end && "end"] .filter(Boolean) .join(" "); return attr || undefined; };
// --- scroll-state commit / visibility scheduling ---------------------------
function updateModeFromScroll(next: MessageScrollerScrollable) { const scrollTop = viewport?.scrollTop ?? 0; const scrolledUp = scrollTop < lastScrollTop - SCROLL_EPSILON; lastScrollTop = scrollTop; if ( autoScroll() && !next.end && mode !== "settling-jump" && mode !== "anchored-to-message" ) { mode = "following-bottom"; } else if ( mode === "following-bottom" && next.end && scrolledUp && !autoscrolling ) { mode = "free-scrolling"; } }
function commitScrollState() { const measured = computeScrollable({ content, scrollEdgeThreshold: scrollEdgeThreshold(), spacer, viewport, }); updateModeFromScroll(measured); const next = mode === "following-bottom" ? { ...measured, end: false } : measured; if (!scrollableEqual(scrollable, next)) scrollable = next; }
function scheduleStateCommit() { if (stateFrame === null) { stateFrame = window.requestAnimationFrame(() => { stateFrame = null; commitScrollState(); }); } }
function scheduleVisibilitySync() { if (visibilityConsumers === 0) return; if (visibilityFrame === null) { visibilityFrame = window.requestAnimationFrame(() => { visibilityFrame = null; if (visibilityConsumers > 0) { const next = computeVisibility({ content, scrollMargin: scrollMargin(), scrollPreviousItemPeek: scrollPreviousItemPeek(), spacer, viewport, visibleMessageIds, }); if (!visibilityEqual(visibility, next)) visibility = next; } }); } }
// --- imperative scroll primitives ------------------------------------------
function setAutoscrolling(active: boolean) { if (autoscrollingTimeout !== null) { window.clearTimeout(autoscrollingTimeout); autoscrollingTimeout = null; } if (autoscrolling !== active) { autoscrolling = active; commitScrollState(); } if (active) { autoscrollingTimeout = window.setTimeout(() => { autoscrollingTimeout = null; autoscrolling = false; commitScrollState(); }, AUTOSCROLLING_TIMEOUT); } }
function setSpacerHeight(height: number) { if (!spacer) return; const next = Math.max(0, Math.ceil(height)); if (spacerHeight !== next) { spacerHeight = next; spacer.hidden = next === 0; spacer.style.height = `${next}px`; spacer.style.marginTop = next > 0 ? `${-spacerGap}px` : ""; } }
function scrollTo( top: number, { behavior = "auto", autoscrolling: isAutoscrolling = false, }: { behavior?: ScrollBehavior; autoscrolling?: boolean } = {}, ) { if (!viewport) return; const target = Math.max(0, top); if (Math.abs(viewport.scrollTop - target) <= SCROLL_EPSILON) { viewport.scrollTop = target; commitScrollState(); return; } if (isAutoscrolling) setAutoscrolling(true); viewport.scrollTo({ top: target, behavior }); scheduleStateCommit(); }
function scrollToStart({ behavior = "auto", }: { behavior?: ScrollBehavior; } = {}): boolean { if (!viewport) return false; setSpacerHeight(0); streamingTurn = null; mode = "free-scrolling"; scrollTo(0, { behavior }); scheduleVisibilitySync(); return true; }
function scrollToEnd({ behavior = "auto", }: { behavior?: ScrollBehavior; } = {}): boolean { if (!viewport) return false; setSpacerHeight(0); streamingTurn = null; mode = autoScroll() ? "following-bottom" : "free-scrolling"; scrollTo(maxScrollTop(viewport), { autoscrolling: true, behavior }); scheduleVisibilitySync(); return true; }
function scrollToElement( element: HTMLElement, { align = "start", behavior = "auto", scrollMargin: margin = scrollMargin(), }: MessageScrollerScrollOptions = {}, { keepPreviousPeek = false }: { keepPreviousPeek?: boolean } = {}, ): boolean { if (!content || !viewport || !content.contains(element)) return false; const targetScrollTop = computeScrollTopForElement({ align, element, scrollMargin: keepPreviousPeek ? margin + scrollPreviousItemPeek() : margin, spacer, viewport, }); setSpacerHeight( computeSpacerHeight({ content, scrollTop: targetScrollTop, spacer, viewport, }), ); prependRestore = { element, viewportTop: getRelativeTop(element, viewport), }; mode = keepPreviousPeek ? "anchored-to-message" : "settling-jump"; streamingTurn = keepPreviousPeek ? element : null; scrollTo(targetScrollTop, { behavior }); scheduleVisibilitySync(); return true; }
function reanchorToAnchoredMessage(): boolean { if (!streamingTurn?.isConnected || mode !== "anchored-to-message") return false; return scrollToElement( streamingTurn, { align: "start" }, { keepPreviousPeek: true }, ); }
function scrollToMessage( messageId: string, options?: MessageScrollerScrollOptions, ): boolean { const element = messageElements.get(messageId); if (element) { defaultScrollPositionApplied = true; if (scrollToElement(element, options)) { pendingScrollToMessage = null; return true; } pendingScrollToMessage = { messageId, options }; return true; } if (itemCount === 0) { pendingScrollToMessage = { messageId, options }; defaultScrollPositionApplied = true; return true; } return false; }
function flushPendingScrollToMessage(): boolean { if (!pendingScrollToMessage) return false; const element = messageElements.get(pendingScrollToMessage.messageId); if (!element || !scrollToElement(element, pendingScrollToMessage.options)) return false; pendingScrollToMessage = null; defaultScrollPositionApplied = true; return true; }
// --- prepend preservation --------------------------------------------------
function applyPrependRestore(): boolean { if (!prependRestore || !viewport || !prependRestore.element.isConnected) return false; const delta = getRelativeTop(prependRestore.element, viewport) - prependRestore.viewportTop; if (Math.abs(delta) <= SCROLL_EPSILON) return false; viewport.scrollTop += delta; prependRestore.viewportTop = getRelativeTop( prependRestore.element, viewport, ); scheduleStateCommit(); scheduleVisibilitySync(); return true; }
function capturePrependAnchor() { if (!content || !viewport) { prependRestore = null; return; } const element = findFirstVisibleMessage({ content, spacer, viewport }); prependRestore = element ? { element, viewportTop: getRelativeTop(element, viewport) } : null; }
function schedulePrependFlush() { if (pendingScrollFrame === null) { pendingScrollFrame = window.requestAnimationFrame(() => { pendingScrollFrame = null; if (flushPendingScrollToMessage()) capturePrependAnchor(); }); } }
// --- default scroll position -----------------------------------------------
function applyDefaultScrollPosition(): boolean { if (defaultScrollPositionApplied || itemCount === 0) return false; const position = defaultScrollPosition(); let applied = false; if (position === "last-anchor") { const lastAnchor = content && viewport ? findLastAnchor(getMessageChildren(content, spacer)) : null; if (!content || !viewport || !lastAnchor) { applied = scrollToEnd({ behavior: "auto" }); } else { const anchorOffset = getElementOffsetTop(lastAnchor, viewport); const contentHeight = measureContentHeight({ content, spacer, viewport, }); applied = contentHeight - anchorOffset <= viewport.clientHeight ? scrollToEnd({ behavior: "auto" }) : scrollToElement( lastAnchor, { align: "start" }, { keepPreviousPeek: true }, ); } } else { applied = position === "end" ? scrollToEnd({ behavior: "auto" }) : scrollToStart({ behavior: "auto" }); } if (applied) { defaultScrollPositionApplied = true; return true; } return false; }
// --- content / resize handling ---------------------------------------------
function applyContentChange( children: HTMLElement[], previousCount: number, previousFirst: HTMLElement | null, ) { if (flushPendingScrollToMessage()) return; if (previousCount === 0) { if ( applyDefaultScrollPosition() || (children.length > 0 && autoScroll() && scrollToEnd({ behavior: "auto" })) ) { return; } commitScrollState(); scheduleVisibilitySync(); return; } const previousIndex = previousFirst ? children.indexOf(previousFirst) : -1; if (preserveScrollOnPrepend && previousIndex > 0) { applyPrependRestore(); return; } if (children.length > previousCount) { const anchor = findFirstAnchorFrom(children, previousCount); if (anchor) { if ( autoScroll() && mode === "following-bottom" && hasMultipleAnchorsFrom(children, previousCount) ) { scrollToEnd({ behavior: "auto" }); return; } scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true }); handledScrollAnchors.add(anchor); return; } } if (children.length === previousCount) { const anchor = findFirstUnhandledAnchor(children, handledScrollAnchors); if (anchor) { scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true }); handledScrollAnchors.add(anchor); return; } } if (mode === "following-bottom" && autoScroll()) { scrollToEnd({ behavior: "auto" }); } else { commitScrollState(); scheduleVisibilitySync(); } }
function handleContentChange() { if (!content) return; const children = getMessageChildren(content, spacer); const previousCount = itemCount; const previousFirst = firstItem; itemCount = children.length; firstItem = children[0] ?? null;
applyContentChange(children, previousCount, previousFirst); capturePrependAnchor(); }
function handleResize() { if (mode === "following-bottom" && autoScroll()) { scrollToEnd({ behavior: "auto" }); return; } const previousSpacerHeight = spacerHeight; if (reanchorToAnchoredMessage()) { // The reply streaming below the anchor consumes the tail spacer as it // grows. Once the last of it is gone the reply has filled the viewport // and the reader is genuinely at the live edge, so autoScroll hands off // from the anchor hold to following the bottom. Requiring the >0 → 0 // transition keeps a turn taller than the viewport (placed with no // spacer) held instead of yanked to the end. if (autoScroll() && previousSpacerHeight > 0 && spacerHeight === 0) scrollToEnd({ behavior: "auto" }); return; } scheduleStateCommit(); scheduleVisibilitySync(); }
// --- visibility observation ------------------------------------------------
function observeVisibility() { if (!viewport || visibilityConsumers === 0) return; if (typeof IntersectionObserver === "undefined") { scheduleVisibilitySync(); return; } if (!visibilityObserver) { visibilityObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const messageId = (entry.target as HTMLElement).dataset.messageId; if (!messageId) continue; if (entry.isIntersecting) visibleMessageIds.add(messageId); else visibleMessageIds.delete(messageId); } scheduleVisibilitySync(); }, { root: viewport, rootMargin: `${-(scrollMargin() + scrollPreviousItemPeek())}px 0px 0px 0px`, threshold: [0, 0.01, 0.5, 1], }, ); } messageElements.forEach((element) => { visibilityObserver?.observe(element); }); scheduleVisibilitySync(); }
function unobserveVisibility() { if (visibilityFrame !== null) { window.cancelAnimationFrame(visibilityFrame); visibilityFrame = null; } visibilityObserver?.disconnect(); visibilityObserver = null; visibleMessageIds.clear(); if (!visibilityEqual(visibility, EMPTY_VISIBILITY)) visibility = EMPTY_VISIBILITY; }
function acquireVisibility() { visibilityConsumers += 1; if (visibilityConsumers === 1) observeVisibility(); }
function releaseVisibility() { visibilityConsumers -= 1; if (visibilityConsumers === 0) unobserveVisibility(); }
const registerMessage: RegisterMessage = ( messageId, element, previousElement, ) => { if (element) { messageElements.set(messageId, element); visibilityObserver?.observe(element); scheduleVisibilitySync(); if (pendingScrollToMessage?.messageId === messageId) schedulePrependFlush(); return; } if (previousElement && messageElements.get(messageId) === previousElement) { messageElements.delete(messageId); visibleMessageIds.delete(messageId); visibilityObserver?.unobserve(previousElement); scheduleVisibilitySync(); } };
// --- user intent + element setters -----------------------------------------
function userScrollIntent() { if ( mode === "following-bottom" || mode === "anchored-to-message" || mode === "settling-jump" ) { streamingTurn = null; mode = "free-scrolling"; } }
function setViewportElement(element: HTMLElement | null) { viewport = element; // A visibility consumer may have subscribed before the viewport mounted, // in which case observeVisibility() bailed out. Retry now it exists. if (element) observeVisibility(); }
function setContentElement(element: HTMLElement | null) { content = element; }
function setSpacerElement(element: HTMLElement | null) { spacer = element; spacerGap = getRowGap(element?.parentElement ?? null); }
function setPreserveScrollOnPrepend(value: boolean) { preserveScrollOnPrepend = value; }
function syncAfterScroll() { commitScrollState(); scheduleVisibilitySync(); capturePrependAnchor(); }
function onAutoScrollChange() { if (autoScroll() && mode === "following-bottom" && itemCount > 0) { scrollToEnd({ behavior: "auto" }); return; } commitScrollState(); }
function destroy() { if (stateFrame !== null) { window.cancelAnimationFrame(stateFrame); stateFrame = null; } if (visibilityFrame !== null) { window.cancelAnimationFrame(visibilityFrame); visibilityFrame = null; } if (autoscrollingTimeout !== null) { window.clearTimeout(autoscrollingTimeout); autoscrollingTimeout = null; } if (pendingScrollFrame !== null) { window.cancelAnimationFrame(pendingScrollFrame); pendingScrollFrame = null; } visibilityObserver?.disconnect(); visibilityObserver = null; }
const context: MessageScrollerContext = { get autoscrolling() { return autoscrolling; }, get scrollable() { return scrollable; }, get scrollableAttr() { return scrollableAttr(); }, get visibility() { return visibility; }, acquireVisibility, releaseVisibility, handleContentChange, handleResize, scrollToEnd, scrollToMessage, scrollToStart, setContentElement, setSpacerElement, setViewportElement, setPreserveScrollOnPrepend, syncAfterScroll, userScrollIntent, };
return { context, registerMessage, applyDefaultScrollPosition, onAutoScrollChange, destroy, };}
// -----------------------------------------------------------------------------// Provider wiring (provide/inject)// -----------------------------------------------------------------------------
export function provideMessageScroller(props: MessageScrollerProviderProps) { const engine = createEngine(props); setContext(CONTEXT_KEY, engine.context); setContext(REGISTER_KEY, engine.registerMessage);
let firstRun = true; $effect(() => { void (props.autoScroll ?? false); if (firstRun) { firstRun = false; return; } engine.onAutoScrollChange(); });
onMount(() => { engine.applyDefaultScrollPosition(); // The viewport element attaches in MessageScrollerViewport's onMount, // after MessageScrollerContent ran its initial handleContentChange without // it. Re-sync now that every element is wired up. engine.context.syncAfterScroll(); });
onDestroy(() => engine.destroy());
return engine;}
export function useMessageScrollerContext(): MessageScrollerContext { const context = getContext<MessageScrollerContext | undefined>(CONTEXT_KEY); if (!context) throw new Error( "useMessageScroller must be used within a MessageScroller.", ); return context;}
export function useMessageScrollerRegister(): RegisterMessage { const register = getContext<RegisterMessage | undefined>(REGISTER_KEY); if (!register) throw new Error( "MessageScrollerItem must be used within a MessageScroller.", ); return register;}
// -----------------------------------------------------------------------------// Public hooks (call during component init). Reactive values are read through `.current`.// -----------------------------------------------------------------------------
export function useMessageScroller() { const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScrollerContext(); return { scrollToEnd, scrollToMessage, scrollToStart };}
export function useMessageScrollerScrollable(): { readonly current: MessageScrollerScrollable;} { const context = useMessageScrollerContext(); return { get current() { return context.scrollable; }, };}
export function useMessageScrollerVisibility(): { readonly current: MessageScrollerVisibilityState;} { const context = useMessageScrollerContext(); context.acquireVisibility(); onDestroy(context.releaseVisibility); return { get current() { return context.visibility; }, };}
export { SCROLL_KEYS };import Root from "./message-scroller.svelte";import Button from "./message-scroller-button.svelte";import Content from "./message-scroller-content.svelte";import Item from "./message-scroller-item.svelte";import Provider from "./message-scroller-provider.svelte";import Viewport from "./message-scroller-viewport.svelte";
export { type MessageScrollerButtonDirection, type MessageScrollerDefaultScrollPosition, type MessageScrollerProviderProps, type MessageScrollerScrollAlign, type MessageScrollerScrollable, type MessageScrollerScrollOptions, type MessageScrollerVisibilityState, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility,} from "./use-message-scroller.svelte.js";export { Button, Button as MessageScrollerButton, Content, Content as MessageScrollerContent, Item, Item as MessageScrollerItem, Provider, Provider as MessageScrollerProvider, Root, // Root as MessageScroller, Viewport, Viewport as MessageScrollerViewport,};| Part | Prop | Type | Default |
|---|---|---|---|
| MessageScrollerProvider | autoScroll | boolean |
false |
| MessageScrollerItem | messageId | string |
none |
| MessageScrollerItem | scrollAnchor | boolean |
false |
| MessageScrollerButton | direction | start | end |
end |
| MessageScrollerButton | variant, size | Button props | outline, sm |
Built on @shadcn/react/message-scroller; the hooks useMessageScroller, useMessageScrollerScrollable and useMessageScrollerVisibility are re-exported.
✦ Edmi additions
Section titled “✦ Edmi additions”- The scroll button is a labeled pill (“Jump to latest”) instead of an icon-only button.