// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message-scroller.tsx // Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md // 改造:保留"离底部 followThreshold 以内才跟随、滚轮/触摸/上翻键主动离开、ResizeObserver 跟随内容增长"的状态机; // 去掉 PreviewRail 导航;新增"回到底部"按钮(离开跟随时出现,点了恢复跟随);reduced motion 下不用平滑滚动; // 程序滚动的静默期取 --duration-layout 的值,不写字面量时长;log 只播报新增行(additions),流式正文不逐字播报。 import { ArrowDown } from "lucide-react"; import { type ComponentPropsWithRef, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { cn } from "~/lib/utils.ts"; import { useReducedMotion } from "./use-reduced-motion.ts"; export interface MessageScrollerProps extends Omit, "children"> { children: ReactNode; /** 用户停在底部附近时,新内容自动滚到底 */ followOutput?: boolean; /** 离底部多少像素内仍算"跟随中" */ followThreshold?: number; onFollowChange?: (following: boolean) => void; /** 滚动区的无障碍名称 */ label?: string; /** 还在等待流式内容 */ busy?: boolean; viewportClassName?: string; contentClassName?: string; /** "回到底部"按钮文案 */ jumpLabel?: string; } function layoutMs(el: Element | null): number { if (!el) return 0; const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim(); const n = Number.parseFloat(v); return Number.isFinite(n) ? (v.endsWith("ms") ? n : n * 1000) : 0; } export function MessageScroller({ followOutput = true, followThreshold = 56, onFollowChange, label = "对话记录", busy, viewportClassName, contentClassName, jumpLabel = "回到底部", className, children, ...props }: MessageScrollerProps) { const reduce = useReducedMotion(); const viewportRef = useRef(null); const contentRef = useRef(null); const followingRef = useRef(followOutput); const programmatic = useRef(false); const timer = useRef(undefined); const [following, setFollowingState] = useState(true); const setFollowing = useCallback( (next: boolean) => { if (followingRef.current === next) return; followingRef.current = next; setFollowingState(next); onFollowChange?.(next); }, [onFollowChange], ); const scrollToEnd = useCallback((behavior: ScrollBehavior) => { const vp = viewportRef.current; if (!vp) return; programmatic.current = true; if (typeof vp.scrollTo === "function") vp.scrollTo({ top: vp.scrollHeight, behavior }); else vp.scrollTop = vp.scrollHeight; if (timer.current) window.clearTimeout(timer.current); // 平滑滚动的静默期:等 scrollend,兜底取 3 × --duration-layout const done = () => { programmatic.current = false; vp.removeEventListener("scrollend", done); }; vp.addEventListener("scrollend", done, { once: true }); timer.current = window.setTimeout(done, behavior === "smooth" ? layoutMs(vp) * 3 : 0); }, []); const lastTop = useRef(0); const onScroll = useCallback(() => { const vp = viewportRef.current; if (!vp) return; const up = vp.scrollTop < lastTop.current - 1; lastTop.current = vp.scrollTop; const nearEnd = vp.scrollHeight - vp.scrollTop - vp.clientHeight <= followThreshold; // 回到底部附近:恢复跟随;离开底部:只有"往上翻"才算用户离开(程序平滑滚动与内容增长只会往下) if (nearEnd) setFollowing(true); else if (up && !programmatic.current) setFollowing(false); }, [followThreshold, setFollowing]); const leaveLiveEdge = useCallback(() => { programmatic.current = false; }, []); useLayoutEffect(() => { followingRef.current = followOutput; if (!followOutput) return; const f = requestAnimationFrame(() => scrollToEnd("auto")); return () => cancelAnimationFrame(f); }, [followOutput, scrollToEnd]); useEffect(() => { const content = contentRef.current; if (!content || typeof ResizeObserver === "undefined") return; // 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随 let first = true; const ro = new ResizeObserver(() => { if (!followOutput || !followingRef.current) return; scrollToEnd(reduce || first ? "auto" : "smooth"); first = false; }); ro.observe(content); return () => ro.disconnect(); }, [followOutput, reduce, scrollToEnd]); useEffect( () => () => { if (timer.current) window.clearTimeout(timer.current); }, [], ); return (
{ if (["ArrowUp", "PageUp", "Home"].includes(e.key)) leaveLiveEdge(); }} className={cn( "h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] [scrollbar-gutter:stable] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", viewportClassName, )} >
{children}
{!following ? ( ) : null}
); }