"use client"; import { useReducedMotion } from "motion/react"; import { type ComponentPropsWithRef, type Ref, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { PreviewRail, type PreviewRailItem, } from "@/components/motion/preview-rail"; import { cn } from "@/lib/utils"; const PREVIEW_TITLE_LENGTH = 56; const PREVIEW_DESCRIPTION_LENGTH = 88; function truncateMessageText(text: string, limit: number) { if (text.length <= limit) return text; const excerpt = text.slice(0, limit); const boundary = excerpt.lastIndexOf(" "); return `${excerpt.slice(0, boundary > limit * 0.65 ? boundary : limit).trim()}…`; } function getMessageText(message: HTMLElement) { const surface = message.querySelector('[data-slot="message-bubble-content"]') ?? message.querySelector('[data-slot="message-content"]') ?? message; return (surface.textContent ?? "").replace(/\s+/g, " ").trim(); } function getMessagePreview( message: HTMLElement, assistantResponse?: HTMLElement, ) { const text = getMessageText(message); if (!text) { return { label: "Message", description: undefined }; } if (text.length <= PREVIEW_TITLE_LENGTH) { const responseText = assistantResponse ? getMessageText(assistantResponse) : ""; return { label: text, description: responseText ? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH) : undefined, }; } const titleExcerpt = text.slice(0, PREVIEW_TITLE_LENGTH); const titleBoundary = titleExcerpt.lastIndexOf(" "); const titleEnd = titleBoundary > PREVIEW_TITLE_LENGTH * 0.65 ? titleBoundary : PREVIEW_TITLE_LENGTH; const label = `${text.slice(0, titleEnd).trim()}…`; const responseText = assistantResponse ? getMessageText(assistantResponse) : text.slice(titleEnd).trim(); return { label, description: responseText ? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH) : undefined, }; } export interface MessageScrollerProps extends ComponentPropsWithRef<"div"> { /** Keep streamed output pinned while the reader remains near the end. */ followOutput?: boolean; /** Distance from the end that still counts as following the output. */ followThreshold?: number; /** Smoothly follow growing content. */ smooth?: boolean; /** Reports when the reader leaves or returns to the live edge. */ onFollowChange?: (following: boolean) => void; /** Accessible label for the scrollable transcript. */ label?: string; /** Marks the transcript as waiting for more streamed content. */ busy?: boolean; /** Adds a compact rail for navigating between rendered Message rows. */ navigation?: "rail"; /** Accessible label for the optional message navigation rail. */ navigationLabel?: string; viewportClassName?: string; contentClassName?: string; railClassName?: string; viewportRef?: Ref; viewportProps?: Omit< ComponentPropsWithRef<"section">, "children" | "className" | "ref" >; contentProps?: Omit< ComponentPropsWithRef<"div">, "children" | "className" | "ref" >; } export function MessageScroller({ followOutput = true, followThreshold = 56, smooth = true, onFollowChange, label = "Conversation", busy, navigation, navigationLabel = "Message navigation", viewportClassName, contentClassName, railClassName, viewportRef: externalViewportRef, viewportProps, contentProps, className, children, ...props }: MessageScrollerProps) { const reduce = useReducedMotion() ?? false; const viewportRef = useRef(null); const contentRef = useRef(null); const followingRef = useRef(followOutput); const programmaticScrollRef = useRef(false); const scrollTimerRef = useRef(undefined); const frameRef = useRef(undefined); const railFrameRef = useRef(undefined); const railIdRef = useRef(new WeakMap()); const railIdCounterRef = useRef(0); const railTargetsRef = useRef(new Map()); const [railItems, setRailItems] = useState([]); const [activeRailId, setActiveRailId] = useState(""); const [railOverflowing, setRailOverflowing] = useState(false); const { onScroll: onViewportScroll, onWheel: onViewportWheel, onTouchStart: onViewportTouchStart, onKeyDown: onViewportKeyDown, ...restViewportProps } = viewportProps ?? {}; const setViewportRef = useCallback( (node: HTMLElement | null) => { viewportRef.current = node; if (typeof externalViewportRef === "function") { externalViewportRef(node); } else if (externalViewportRef) { externalViewportRef.current = node; } }, [externalViewportRef], ); const setFollowing = useCallback( (next: boolean) => { if (followingRef.current === next) return; followingRef.current = next; onFollowChange?.(next); }, [onFollowChange], ); const updateActiveRailItem = useCallback(() => { if (navigation !== "rail") return; const viewport = viewportRef.current; const targets = [...railTargetsRef.current.entries()]; if (!viewport || targets.length === 0) return; const viewportRect = viewport.getBoundingClientRect(); if (viewport.scrollTop <= followThreshold) { const firstId = targets[0]?.[0] ?? ""; setActiveRailId((current) => (current === firstId ? current : firstId)); return; } const distanceFromEnd = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; if (distanceFromEnd <= followThreshold) { const lastId = targets.at(-1)?.[0] ?? ""; setActiveRailId((current) => (current === lastId ? current : lastId)); return; } const viewportCenter = viewportRect.top + viewportRect.height / 2; let nearestId = targets[0]?.[0] ?? ""; let nearestDistance = Number.POSITIVE_INFINITY; for (const [id, element] of targets) { const rect = element.getBoundingClientRect(); const messageCenter = rect.top + rect.height / 2; const distance = Math.abs(messageCenter - viewportCenter); if (distance < nearestDistance) { nearestDistance = distance; nearestId = id; } } setActiveRailId((current) => current === nearestId ? current : nearestId, ); }, [followThreshold, navigation]); const syncRailItems = useCallback(() => { if (navigation !== "rail") return; const content = contentRef.current; const viewport = viewportRef.current; if (!content || !viewport) return; const messages = Array.from( content.querySelectorAll('[data-slot="message"]'), ); const targets = new Map(); const nextItems = messages.map((message, index) => { let id = railIdRef.current.get(message); if (!id) { railIdCounterRef.current += 1; id = `message-rail-${railIdCounterRef.current}`; railIdRef.current.set(message, id); } targets.set(id, message); const sender = message.dataset.from ?? "conversation"; const assistantResponse = sender === "user" ? messages .slice(index + 1) .find((candidate) => candidate.dataset.from === "assistant") : undefined; const preview = getMessagePreview(message, assistantResponse); return { id, label: preview.label, description: preview.description, ariaLabel: `Go to ${sender} message ${index + 1} of ${messages.length}`, }; }); railTargetsRef.current = targets; setRailItems((current) => { const unchanged = current.length === nextItems.length && current.every( (item, index) => item.id === nextItems[index]?.id && item.label === nextItems[index]?.label && item.description === nextItems[index]?.description && item.ariaLabel === nextItems[index]?.ariaLabel, ); return unchanged ? current : nextItems; }); setRailOverflowing( viewport.scrollHeight > viewport.clientHeight + 1 && messages.length > 1, ); }, [navigation]); const scheduleRailSync = useCallback(() => { if (navigation !== "rail") return; if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current); railFrameRef.current = requestAnimationFrame(() => { syncRailItems(); updateActiveRailItem(); }); }, [navigation, syncRailItems, updateActiveRailItem]); const scrollToEnd = useCallback((behavior: ScrollBehavior) => { const viewport = viewportRef.current; if (!viewport) return; programmaticScrollRef.current = true; if (typeof viewport.scrollTo === "function") { viewport.scrollTo({ top: viewport.scrollHeight, behavior }); } else { viewport.scrollTop = viewport.scrollHeight; } if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current); scrollTimerRef.current = window.setTimeout(() => { programmaticScrollRef.current = false; }, behavior === "smooth" ? 320 : 0); }, []); const handleScroll = useCallback(() => { const viewport = viewportRef.current; if (!viewport || programmaticScrollRef.current) return; const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; setFollowing(distance <= followThreshold); updateActiveRailItem(); }, [followThreshold, setFollowing, updateActiveRailItem]); const leaveLiveEdge = useCallback(() => { programmaticScrollRef.current = false; }, []); useLayoutEffect(() => { followingRef.current = followOutput; if (!followOutput) return; frameRef.current = requestAnimationFrame(() => scrollToEnd("auto")); return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); }; }, [followOutput, scrollToEnd]); useEffect(() => { const content = contentRef.current; if (!content || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { scheduleRailSync(); if (!followOutput || !followingRef.current) return; scrollToEnd(reduce || !smooth ? "auto" : "smooth"); }); observer.observe(content); return () => observer.disconnect(); }, [followOutput, reduce, scheduleRailSync, scrollToEnd, smooth]); useEffect(() => { if (navigation !== "rail") { railTargetsRef.current.clear(); setRailItems([]); setRailOverflowing(false); return; } const content = contentRef.current; const viewport = viewportRef.current; if (!content || !viewport) return; scheduleRailSync(); const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(scheduleRailSync); mutationObserver?.observe(content, { childList: true, characterData: true, subtree: true, }); const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleRailSync); resizeObserver?.observe(content); resizeObserver?.observe(viewport); return () => { mutationObserver?.disconnect(); resizeObserver?.disconnect(); }; }, [navigation, scheduleRailSync]); useEffect( () => () => { if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current); if (frameRef.current) cancelAnimationFrame(frameRef.current); if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current); }, [], ); const scrollToRailItem = useCallback( (item: PreviewRailItem) => { const viewport = viewportRef.current; const target = railTargetsRef.current.get(item.id); if (!viewport || !target) return; const lastItem = railItems.at(-1)?.id === item.id; setActiveRailId(item.id); if (lastItem) { setFollowing(true); scrollToEnd(reduce || !smooth ? "auto" : "smooth"); return; } setFollowing(false); programmaticScrollRef.current = true; const viewportRect = viewport.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const top = viewport.scrollTop + targetRect.top - viewportRect.top - (viewport.clientHeight - targetRect.height) / 2; const behavior = reduce || !smooth ? "auto" : "smooth"; if (typeof viewport.scrollTo === "function") { viewport.scrollTo({ top, behavior }); } else { viewport.scrollTop = top; } if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current); scrollTimerRef.current = window.setTimeout(() => { programmaticScrollRef.current = false; }, behavior === "smooth" ? 320 : 0); }, [railItems, reduce, scrollToEnd, setFollowing, smooth], ); const viewport = (
{ handleScroll(); onViewportScroll?.(event); }} onWheel={(event) => { leaveLiveEdge(); onViewportWheel?.(event); }} onTouchStart={(event) => { leaveLiveEdge(); onViewportTouchStart?.(event); }} onKeyDown={(event) => { if (["ArrowUp", "PageUp", "Home"].includes(event.key)) { leaveLiveEdge(); } onViewportKeyDown?.(event); }} className={cn( "h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", navigation === "rail" ? "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" : "[scrollbar-gutter:stable]", viewportClassName, navigation === "rail" && railOverflowing && "pr-10", )} >
{children}
); return (
{navigation === "rail" ? ( {viewport} ) : ( viewport )}
); }