"use client" /** * MessageScroller — chat transcript scroll behaviors (shadcn MessageScroller). * * Covers: turn anchoring + previous-item peek, autoScroll only at the live * edge (released by wheel/touch/keyboard/selection), default open position, * jump to message / start / end (align + scrollMargin), visibility tracking * (currentAnchorId / visibleMessageIds), prepend preserve, and jump controls. * Styles use Exxat semantic tokens only. */ import * as React from "react" import { cn } from "../../lib/utils" import { Button } from "./button" const DEFAULT_SCROLL_EDGE_THRESHOLD = 8 const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64 const SCROLL_EPSILON = 1 const ANCHOR_EDGE_SLOP = 0.5 const AUTOSCROLLING_MS = 180 const USER_SCROLL_KEYS = new Set([ "ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " ", ]) export type MessageScrollerDefaultScrollPosition = | "start" | "end" | "last-anchor" export type MessageScrollerScrollAlign = | "start" | "center" | "end" | "nearest" export type MessageScrollerScrollOptions = { align?: MessageScrollerScrollAlign behavior?: ScrollBehavior scrollMargin?: number } export type MessageScrollerScrollable = { /** Content is hidden above — can scroll toward the start. */ start: boolean /** Content is hidden below — can scroll toward the end. */ end: boolean } export type MessageScrollerVisibilityState = { currentAnchorId: string | null visibleMessageIds: string[] } type Mode = | "following-bottom" | "free-scrolling" | "anchored-to-message" | "settling-jump" type Store = { getSnapshot: () => T hasListeners: () => boolean setSnapshot: (next: T) => void subscribe: ( listener: () => void, onFirst?: () => void, onLast?: () => void, ) => () => void } type MessageScrollerContextValue = { autoScroll: boolean scrollPreviousItemPeek: number scrollEdgeThreshold: number scrollMargin: number scrollableStore: Store visibilityStore: Store /** @deprecated Prefer `!scrollable.end` — kept for existing Leo call sites. */ isAtBottom: boolean setRootEl: (el: HTMLDivElement | null) => void setViewportEl: (el: HTMLDivElement | null) => void setContentEl: (el: HTMLDivElement | null) => void setSpacerEl: (el: HTMLDivElement | null) => void scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean scrollToStart: (options?: MessageScrollerScrollOptions) => boolean scrollToMessage: ( messageId: string, options?: MessageScrollerScrollOptions, ) => boolean releaseAutoScroll: () => void notifyContentChange: () => void registerScrollAnchor: (el: HTMLElement | null) => void registerMessage: (messageId: string, el: HTMLElement | null) => void onViewportScroll: () => void userScrollIntent: () => void setAutoscrolling: (next: boolean) => void observeVisibility: () => void unobserveVisibility: () => void preserveScrollOnPrependRef: React.MutableRefObject } const EMPTY_VISIBLE: string[] = [] const EMPTY_SCROLLABLE: MessageScrollerScrollable = { start: false, end: false } const EMPTY_VISIBILITY: MessageScrollerVisibilityState = { currentAnchorId: null, visibleMessageIds: EMPTY_VISIBLE, } const MessageScrollerContext = React.createContext(null) function useMessageScrollerContext() { const ctx = React.useContext(MessageScrollerContext) if (!ctx) { throw new Error( "MessageScroller components must be used within MessageScrollerProvider", ) } return ctx } function createStore( initial: T, isEqual: (a: T, b: T) => boolean, ): Store { let snapshot = initial const listeners = new Set<() => void>() return { getSnapshot: () => snapshot, hasListeners: () => listeners.size > 0, setSnapshot: (next) => { if (isEqual(snapshot, next)) return snapshot = next listeners.forEach((listener) => listener()) }, subscribe: (listener, onFirst, onLast) => { const first = listeners.size === 0 listeners.add(listener) if (first) onFirst?.() return () => { listeners.delete(listener) if (listeners.size === 0) onLast?.() } }, } } 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) return false return ( a.visibleMessageIds.length === b.visibleMessageIds.length && a.visibleMessageIds.every((id, i) => id === b.visibleMessageIds[i]) ) } function contentChildren( content: HTMLElement, spacer: HTMLElement | null, ): HTMLElement[] { return Array.from(content.children).filter( (node): node is HTMLElement => node instanceof HTMLElement && node !== spacer, ) } function elementScrollTop(element: HTMLElement, viewport: HTMLElement) { const elementRect = element.getBoundingClientRect() const viewportRect = viewport.getBoundingClientRect() return elementRect.top - viewportRect.top + viewport.scrollTop } function resolveAlignScrollTop({ align, element, scrollMargin, viewport, }: { align: MessageScrollerScrollAlign element: HTMLElement scrollMargin: number viewport: HTMLElement }) { const top = elementScrollTop(element, viewport) const height = element.getBoundingClientRect().height if (align === "center") { return top - (viewport.clientHeight - height) / 2 - scrollMargin } if (align === "end") { return top - viewport.clientHeight + height + scrollMargin } if (align === "nearest") { const bottom = top + height const viewStart = viewport.scrollTop const viewEnd = viewport.scrollTop + viewport.clientHeight if (top >= viewStart && bottom <= viewEnd) return viewport.scrollTop if (top < viewStart) return top - scrollMargin return bottom - viewport.clientHeight + scrollMargin } return top - scrollMargin } function readScrollable( el: HTMLDivElement, threshold: number, ): MessageScrollerScrollable { const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight) return { start: el.scrollTop > threshold, end: maxScroll - el.scrollTop > threshold, } } function readVisibility({ content, scrollMargin, scrollPreviousItemPeek, spacer, viewport, visibleMessageIds, }: { content: HTMLElement | null scrollMargin: number scrollPreviousItemPeek: number spacer: HTMLElement | null viewport: HTMLDivElement | null visibleMessageIds: Set }): MessageScrollerVisibilityState { if (!content || !viewport) return EMPTY_VISIBILITY const viewportRect = viewport.getBoundingClientRect() const edge = viewportRect.top + scrollMargin + scrollPreviousItemPeek const noObserver = typeof IntersectionObserver === "undefined" const ids: string[] = [] let currentAnchorId: string | null = null for (const item of contentChildren(content, spacer)) { const messageId = item.dataset.messageId if (!messageId) continue const isAnchor = item.dataset.scrollAnchor === "true" const rect = isAnchor || noObserver ? item.getBoundingClientRect() : null const visible = noObserver ? !!rect && rect.bottom > edge && rect.top < viewportRect.bottom : visibleMessageIds.has(messageId) if (visible) ids.push(messageId) if (isAnchor && rect && rect.top <= edge + ANCHOR_EDGE_SLOP) { currentAnchorId = messageId } } if (ids.length === 0 && currentAnchorId === null) return EMPTY_VISIBILITY return { currentAnchorId, visibleMessageIds: ids } } function useMessageScroller() { const ctx = useMessageScrollerContext() const scrollable = useMessageScrollerScrollable() return { scrollToEnd: ctx.scrollToEnd, scrollToStart: ctx.scrollToStart, scrollToMessage: ctx.scrollToMessage, releaseAutoScroll: ctx.releaseAutoScroll, scrollable, isAtBottom: ctx.isAtBottom, } } function useMessageScrollerScrollable() { const { scrollableStore } = useMessageScrollerContext() return React.useSyncExternalStore( scrollableStore.subscribe, scrollableStore.getSnapshot, scrollableStore.getSnapshot, ) } function useMessageScrollerVisibility() { const { visibilityStore, observeVisibility, unobserveVisibility } = useMessageScrollerContext() const subscribe = React.useCallback( (listener: () => void) => visibilityStore.subscribe(listener, observeVisibility, unobserveVisibility), [observeVisibility, unobserveVisibility, visibilityStore], ) return React.useSyncExternalStore( subscribe, visibilityStore.getSnapshot, visibilityStore.getSnapshot, ) } function MessageScrollerProvider({ autoScroll = false, defaultScrollPosition = "end", scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD, scrollPreviousItemPeek = DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK, scrollMargin = 0, children, }: { autoScroll?: boolean defaultScrollPosition?: MessageScrollerDefaultScrollPosition scrollEdgeThreshold?: number scrollPreviousItemPeek?: number scrollMargin?: number children: React.ReactNode }) { const rootRef = React.useRef(null) const viewportRef = React.useRef(null) const contentRef = React.useRef(null) const spacerRef = React.useRef(null) const modeRef = React.useRef( autoScroll ? "following-bottom" : "free-scrolling", ) const autoScrollRef = React.useRef(autoScroll) const lastScrollTopRef = React.useRef(0) const pendingAnchorRef = React.useRef(null) // `useRef` takes a value, not a factory, so `useRef(new Map())` would build a // Map on every render and keep only the first. These three start null and are // filled on first use inside the callbacks below, which also keeps them out of // dependency arrays. const messageMapRef = React.useRef | null>(null) const pendingScrollToMessageRef = React.useRef<{ messageId: string options?: MessageScrollerScrollOptions } | null>(null) const defaultAppliedRef = React.useRef(false) const autoscrollingTimeoutRef = React.useRef | null>(null) const itemCountRef = React.useRef(0) const firstItemRef = React.useRef(null) const prependHeightRef = React.useRef(0) const preserveScrollOnPrependRef = React.useRef(true) const visibleMessageIdsRef = React.useRef | null>(null) const visibilityObserverRef = React.useRef(null) const visibilityFrameRef = React.useRef(null) const handledScrollAnchorsRef = React.useRef | null>(null) const scrollableStoreRef = React.useRef | null>( null, ) if (scrollableStoreRef.current === null) { scrollableStoreRef.current = createStore(EMPTY_SCROLLABLE, scrollableEqual) } const visibilityStoreRef = React.useRef | null>(null) if (visibilityStoreRef.current === null) { visibilityStoreRef.current = createStore(EMPTY_VISIBILITY, visibilityEqual) } const scrollableStore = scrollableStoreRef.current const visibilityStore = visibilityStoreRef.current const [autoscrolling, setAutoscrollingState] = React.useState(false) const [isAtBottom, setIsAtBottom] = React.useState(true) // Every reader of this ref runs from a callback, a rAF or a layout effect, so // syncing it after commit is soon enough. Writing it during render is not: // React may replay or throw away a render, and the stale copy would still be // sitting in the ref when a scroll handler next asked what autoScroll was. React.useLayoutEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll]) const writeAttrs = React.useCallback( (next: MessageScrollerScrollable, isAuto: boolean) => { const scrollableAttr = [next.start && "start", next.end && "end"] .filter(Boolean) .join(" ") for (const el of [rootRef.current, viewportRef.current]) { if (!el) continue if (scrollableAttr) el.setAttribute("data-scrollable", scrollableAttr) else el.removeAttribute("data-scrollable") el.toggleAttribute("data-autoscrolling", isAuto) } }, [], ) const publishVisibility = React.useCallback(() => { if (!visibilityStore.hasListeners()) return const next = readVisibility({ content: contentRef.current, scrollMargin, scrollPreviousItemPeek, spacer: spacerRef.current, viewport: viewportRef.current, visibleMessageIds: (visibleMessageIdsRef.current ??= new Set()), }) visibilityStore.setSnapshot(next) }, [scrollMargin, scrollPreviousItemPeek, visibilityStore]) const commitScrollable = React.useCallback(() => { const el = viewportRef.current if (!el) return const next = readScrollable(el, scrollEdgeThreshold) const published = modeRef.current === "following-bottom" ? { ...next, end: false } : next scrollableStore.setSnapshot(published) setIsAtBottom(!published.end) writeAttrs(published, autoscrolling) publishVisibility() }, [ autoscrolling, publishVisibility, scrollEdgeThreshold, scrollableStore, writeAttrs, ]) const setAutoscrolling = React.useCallback( (next: boolean) => { setAutoscrollingState(next) writeAttrs(scrollableStore.getSnapshot(), next) if (autoscrollingTimeoutRef.current) { clearTimeout(autoscrollingTimeoutRef.current) autoscrollingTimeoutRef.current = null } if (next) { autoscrollingTimeoutRef.current = setTimeout(() => { setAutoscrollingState(false) writeAttrs(scrollableStore.getSnapshot(), false) }, AUTOSCROLLING_MS) } }, [scrollableStore, writeAttrs], ) const releaseAutoScroll = React.useCallback(() => { if ( modeRef.current === "following-bottom" || modeRef.current === "anchored-to-message" || modeRef.current === "settling-jump" ) { modeRef.current = "free-scrolling" pendingAnchorRef.current = null } }, []) const userScrollIntent = React.useCallback(() => { releaseAutoScroll() }, [releaseAutoScroll]) const scrollElementIntoView = React.useCallback( ( target: HTMLElement, opts?: { align?: MessageScrollerScrollAlign keepPreviousPeek?: boolean behavior?: ScrollBehavior scrollMargin?: number }, ) => { const el = viewportRef.current if (!el) return false const align = opts?.align ?? "start" const peek = opts?.keepPreviousPeek ? scrollPreviousItemPeek : 0 const margin = (opts?.scrollMargin ?? scrollMargin) + peek const top = resolveAlignScrollTop({ align, element: target, scrollMargin: margin, viewport: el, }) el.scrollTo({ top: Math.max(0, top), behavior: opts?.behavior ?? "smooth", }) requestAnimationFrame(() => { lastScrollTopRef.current = el.scrollTop commitScrollable() }) return true }, [commitScrollable, scrollMargin, scrollPreviousItemPeek], ) const scrollToEnd = React.useCallback( (options?: MessageScrollerScrollOptions) => { const el = viewportRef.current if (!el) return false const behavior = options?.behavior ?? "smooth" if (autoScrollRef.current) modeRef.current = "following-bottom" setAutoscrolling(true) el.scrollTo({ top: el.scrollHeight, behavior }) requestAnimationFrame(() => { lastScrollTopRef.current = el.scrollTop commitScrollable() }) return true }, [commitScrollable, setAutoscrolling], ) const scrollToStart = React.useCallback( (options?: MessageScrollerScrollOptions) => { const el = viewportRef.current if (!el) return false releaseAutoScroll() modeRef.current = "settling-jump" setAutoscrolling(true) el.scrollTo({ top: 0, behavior: options?.behavior ?? "smooth" }) requestAnimationFrame(() => { lastScrollTopRef.current = el.scrollTop modeRef.current = "free-scrolling" commitScrollable() }) return true }, [commitScrollable, releaseAutoScroll, setAutoscrolling], ) const scrollToMessage = React.useCallback( (messageId: string, options?: MessageScrollerScrollOptions) => { const target = (messageMapRef.current ??= new Map()).get(messageId) if (!target) { if (itemCountRef.current === 0) { pendingScrollToMessageRef.current = { messageId, options } return true } return false } pendingScrollToMessageRef.current = null releaseAutoScroll() modeRef.current = "settling-jump" setAutoscrolling(true) const align = options?.align ?? "start" scrollElementIntoView(target, { align, keepPreviousPeek: align === "start", behavior: options?.behavior ?? "smooth", scrollMargin: options?.scrollMargin, }) requestAnimationFrame(() => { modeRef.current = "free-scrolling" commitScrollable() }) return true }, [ commitScrollable, releaseAutoScroll, scrollElementIntoView, setAutoscrolling, ], ) const applyDefaultScrollPosition = React.useCallback(() => { if (defaultAppliedRef.current) return const el = viewportRef.current const content = contentRef.current if (!el || !content) return const items = contentChildren(content, spacerRef.current) if (items.length === 0) return if (defaultScrollPosition === "start") { el.scrollTop = 0 } else if (defaultScrollPosition === "last-anchor") { const anchors = items.filter( (node) => node.dataset.scrollAnchor === "true", ) const last = anchors[anchors.length - 1] if (!last) { el.scrollTop = el.scrollHeight } else { const viewportRect = el.getBoundingClientRect() const lastRect = last.getBoundingClientRect() const contentBottom = content.scrollHeight const anchorTop = el.scrollTop + (lastRect.top - viewportRect.top) const lastTurnFits = contentBottom - anchorTop <= el.clientHeight + SCROLL_EPSILON if (lastTurnFits) { el.scrollTop = el.scrollHeight } else { scrollElementIntoView(last, { keepPreviousPeek: true, behavior: "auto", }) } } } else { el.scrollTop = el.scrollHeight if (autoScrollRef.current) modeRef.current = "following-bottom" } defaultAppliedRef.current = true lastScrollTopRef.current = el.scrollTop commitScrollable() }, [commitScrollable, defaultScrollPosition, scrollElementIntoView]) const notifyContentChange = React.useCallback(() => { const el = viewportRef.current const content = contentRef.current if (!el || !content) return const items = contentChildren(content, spacerRef.current) const previousCount = itemCountRef.current const previousFirst = firstItemRef.current const nextFirst = items[0] ?? null if ( preserveScrollOnPrependRef.current && previousFirst && nextFirst && previousFirst !== nextFirst && items.includes(previousFirst as HTMLElement) && prependHeightRef.current > 0 ) { const delta = el.scrollHeight - prependHeightRef.current if (delta > 0) el.scrollTop += delta } prependHeightRef.current = el.scrollHeight itemCountRef.current = items.length firstItemRef.current = nextFirst const pendingJump = pendingScrollToMessageRef.current if (pendingJump) { const mounted = (messageMapRef.current ??= new Map()).get(pendingJump.messageId) if (mounted) { pendingScrollToMessageRef.current = null scrollToMessage(pendingJump.messageId, pendingJump.options) return } } requestAnimationFrame(() => { if (previousCount === 0) { applyDefaultScrollPosition() return } if (pendingAnchorRef.current?.isConnected) { const anchor = pendingAnchorRef.current pendingAnchorRef.current = null if ( autoScrollRef.current && modeRef.current === "following-bottom" && items.length > previousCount + 1 ) { scrollToEnd({ behavior: "auto" }) return } modeRef.current = "anchored-to-message" scrollElementIntoView(anchor, { keepPreviousPeek: true, behavior: "smooth", }) return } if (modeRef.current === "following-bottom" && autoScrollRef.current) { scrollToEnd({ behavior: "auto" }) return } commitScrollable() }) }, [ applyDefaultScrollPosition, commitScrollable, scrollElementIntoView, scrollToEnd, scrollToMessage, ]) const registerScrollAnchor = React.useCallback((el: HTMLElement | null) => { if (!el) return const handled = (handledScrollAnchorsRef.current ??= new WeakSet()) if (handled.has(el)) return handled.add(el) pendingAnchorRef.current = el }, []) const registerMessage = React.useCallback( (messageId: string, el: HTMLElement | null) => { const messageMap = (messageMapRef.current ??= new Map()) if (el) { messageMap.set(messageId, el) visibilityObserverRef.current?.observe(el) } else { const existing = messageMap.get(messageId) if (existing) visibilityObserverRef.current?.unobserve(existing) messageMap.delete(messageId) visibleMessageIdsRef.current?.delete(messageId) } }, [], ) const onViewportScroll = React.useCallback(() => { const el = viewportRef.current if (!el) return const scrolledUp = el.scrollTop < lastScrollTopRef.current - SCROLL_EPSILON lastScrollTopRef.current = el.scrollTop const next = readScrollable(el, scrollEdgeThreshold) if ( modeRef.current === "following-bottom" && next.end && scrolledUp && !autoscrolling ) { modeRef.current = "free-scrolling" } else if ( autoScrollRef.current && !next.end && modeRef.current !== "settling-jump" && modeRef.current !== "anchored-to-message" ) { modeRef.current = "following-bottom" } commitScrollable() }, [autoscrolling, commitScrollable, scrollEdgeThreshold]) const observeVisibility = React.useCallback(() => { const viewport = viewportRef.current if (!viewport || !visibilityStore.hasListeners()) return if (typeof IntersectionObserver === "undefined") { publishVisibility() return } if (!visibilityObserverRef.current) { visibilityObserverRef.current = new IntersectionObserver( (entries) => { for (const entry of entries) { const id = (entry.target as HTMLElement).dataset.messageId if (!id) continue const visible = (visibleMessageIdsRef.current ??= new Set()) if (entry.isIntersecting) visible.add(id) else visible.delete(id) } if (visibilityFrameRef.current != null) { window.cancelAnimationFrame(visibilityFrameRef.current) } visibilityFrameRef.current = window.requestAnimationFrame(() => { visibilityFrameRef.current = null publishVisibility() }) }, { root: viewport, rootMargin: `${-(scrollMargin + scrollPreviousItemPeek)}px 0px 0px 0px`, threshold: [0, 0.01, 0.5, 1], }, ) } messageMapRef.current?.forEach((node) => { visibilityObserverRef.current?.observe(node) }) publishVisibility() }, [ publishVisibility, scrollMargin, scrollPreviousItemPeek, visibilityStore, ]) const unobserveVisibility = React.useCallback(() => { if (visibilityFrameRef.current != null) { window.cancelAnimationFrame(visibilityFrameRef.current) visibilityFrameRef.current = null } visibilityObserverRef.current?.disconnect() visibilityObserverRef.current = null visibleMessageIdsRef.current?.clear() visibilityStore.setSnapshot(EMPTY_VISIBILITY) }, [visibilityStore]) const setRootEl = React.useCallback((el: HTMLDivElement | null) => { rootRef.current = el }, []) const setViewportEl = React.useCallback( (el: HTMLDivElement | null) => { viewportRef.current = el if (el) { lastScrollTopRef.current = el.scrollTop commitScrollable() if (visibilityStore.hasListeners()) observeVisibility() } }, [commitScrollable, observeVisibility, visibilityStore], ) const setContentEl = React.useCallback((el: HTMLDivElement | null) => { contentRef.current = el }, []) const setSpacerEl = React.useCallback((el: HTMLDivElement | null) => { spacerRef.current = el }, []) React.useEffect(() => { const content = contentRef.current if (!content || typeof ResizeObserver === "undefined") return const ro = new ResizeObserver(() => { if (modeRef.current === "following-bottom" && autoScrollRef.current) { const el = viewportRef.current if (!el) return el.scrollTop = el.scrollHeight lastScrollTopRef.current = el.scrollTop commitScrollable() return } commitScrollable() }) ro.observe(content) return () => ro.disconnect() }, [commitScrollable]) React.useLayoutEffect(() => { applyDefaultScrollPosition() }, [applyDefaultScrollPosition]) React.useEffect(() => { return () => { if (autoscrollingTimeoutRef.current) { clearTimeout(autoscrollingTimeoutRef.current) } unobserveVisibility() } }, [unobserveVisibility]) const value = React.useMemo( () => ({ autoScroll, scrollPreviousItemPeek, scrollEdgeThreshold, scrollMargin, scrollableStore, visibilityStore, isAtBottom, setRootEl, setViewportEl, setContentEl, setSpacerEl, scrollToEnd, scrollToStart, scrollToMessage, releaseAutoScroll, notifyContentChange, registerScrollAnchor, registerMessage, onViewportScroll, userScrollIntent, setAutoscrolling, observeVisibility, unobserveVisibility, preserveScrollOnPrependRef, }), [ autoScroll, scrollPreviousItemPeek, scrollEdgeThreshold, scrollMargin, scrollableStore, visibilityStore, isAtBottom, setRootEl, setViewportEl, setContentEl, setSpacerEl, scrollToEnd, scrollToStart, scrollToMessage, releaseAutoScroll, notifyContentChange, registerScrollAnchor, registerMessage, onViewportScroll, userScrollIntent, setAutoscrolling, observeVisibility, unobserveVisibility, ], ) return ( {children} ) } function MessageScroller({ className, ref, ...props }: React.ComponentProps<"div">) { const { setRootEl } = useMessageScrollerContext() const handleRef = React.useCallback( (node: HTMLDivElement | null) => { setRootEl(node) if (typeof ref === "function") ref(node) else if (ref) ref.current = node }, [ref, setRootEl], ) return (
) } function MessageScrollerViewport({ className, ref, onScroll, onWheel, onTouchMove, onKeyDown, onPointerDown, preserveScrollOnPrepend = true, role = "region", "aria-label": ariaLabel = "Messages", tabIndex = 0, ...props }: React.ComponentProps<"div"> & { preserveScrollOnPrepend?: boolean }) { const { setViewportEl, onViewportScroll, userScrollIntent, preserveScrollOnPrependRef, } = useMessageScrollerContext() preserveScrollOnPrependRef.current = preserveScrollOnPrepend const handleRef = React.useCallback( (node: HTMLDivElement | null) => { setViewportEl(node) if (typeof ref === "function") ref(node) else if (ref) ref.current = node }, [ref, setViewportEl], ) return (
{ onViewportScroll() onScroll?.(event) }} onWheel={(event) => { userScrollIntent() onWheel?.(event) }} onTouchMove={(event) => { userScrollIntent() onTouchMove?.(event) }} onPointerDown={(event) => { // Selecting text / dragging is reader intent — release follow-output. userScrollIntent() onPointerDown?.(event) }} onKeyDown={(event) => { if (USER_SCROLL_KEYS.has(event.key)) { userScrollIntent() } onKeyDown?.(event) }} className={cn( "size-full min-h-0 min-w-0 overflow-y-auto overscroll-y-contain contain-content [scrollbar-gutter:stable] [scrollbar-width:thin] [-webkit-overflow-scrolling:touch]", "[mask-image:linear-gradient(to_bottom,black_0%,black_calc(100%-1.25rem),transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,black_0%,black_calc(100%-1.25rem),transparent_100%)]", "data-[autoscrolling]:[scrollbar-width:none] data-[autoscrolling]:[-ms-overflow-style:none] data-[autoscrolling]:[&::-webkit-scrollbar]:hidden", className, )} {...props} /> ) } function MessageScrollerContent({ className, children, ref, spacerClassName, ...props }: React.ComponentProps<"div"> & { spacerClassName?: string }) { const { notifyContentChange, setContentEl, setSpacerEl } = useMessageScrollerContext() const handleRef = React.useCallback( (node: HTMLDivElement | null) => { setContentEl(node) if (typeof ref === "function") ref(node) else if (ref) ref.current = node }, [ref, setContentEl], ) React.useEffect(() => { notifyContentChange() }, [children, notifyContentChange]) return (
{children} ) } function MessageScrollerItem({ className, scrollAnchor = false, messageId, children, ...props }: React.ComponentProps<"div"> & { scrollAnchor?: boolean messageId?: string }) { const { registerScrollAnchor, registerMessage } = useMessageScrollerContext() const itemRef = React.useRef(null) const wasAnchorRef = React.useRef(false) React.useLayoutEffect(() => { const node = itemRef.current if (messageId) registerMessage(messageId, node) return () => { if (messageId) registerMessage(messageId, null) } }, [messageId, registerMessage, children]) React.useLayoutEffect(() => { if (!scrollAnchor || !itemRef.current) { wasAnchorRef.current = false return } if (!wasAnchorRef.current) { registerScrollAnchor(itemRef.current) } wasAnchorRef.current = true }, [registerScrollAnchor, scrollAnchor, children]) return (
{children}
) } function MessageScrollerButton({ direction = "end", behavior = "smooth", className, children, variant = "secondary", size = "icon-sm", ...props }: React.ComponentProps<"button"> & { direction?: "start" | "end" behavior?: ScrollBehavior variant?: React.ComponentProps["variant"] size?: React.ComponentProps["size"] }) { const scrollable = useMessageScrollerScrollable() const { scrollToEnd, scrollToStart } = useMessageScrollerContext() const active = direction === "end" ? scrollable.end : scrollable.start return (