Files
UHD_Media_Tool/apps/web/app/components/agentui/message-scroller.tsx
T
rootandClaude Opus 5.5 93a5341688 fix(agent): 切回看过的会话直接铺出历史,长内容不再从顶滚到底
- 事件流按会话缓存最近 30 条会话的已读事件,切回时直接用缓存渲染,再从最后一个 seq 之后补读
- 消息区跟随:一次多出一屏以上直接跳到底,流式小段增长才平滑跟随
  (原来只有第一次 ResizeObserver 回调算首屏,事件补读回来时整段平滑滚动)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 18:04:14 +08:00

188 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<ComponentPropsWithRef<"div">, "children"> {
children: ReactNode;
/** 用户停在底部附近时,新内容自动滚到底 */
followOutput?: boolean;
/** 离底部多少像素内仍算"跟随中" */
followThreshold?: number;
onFollowChange?: (following: boolean) => void;
/** 滚动区的无障碍名称 */
label?: string;
/** 还在等待流式内容 */
busy?: boolean;
viewportClassName?: string;
contentClassName?: string;
/** "回到底部"按钮文案 */
jumpLabel?: string;
}
/**
* 跟随内容增长时的滚动方式:流式输出的小段增长平滑跟随;一次多出一屏以上(打开 / 切回会话时历史铺开、
* 补读回一大段)直接跳到底,不播一段从上滚到底的动画;减少动态效果时一律直接跳。
*/
export function followBehavior(o: { gap: number; viewport: number; reduce: boolean }): ScrollBehavior {
return o.reduce || o.gap > o.viewport ? "auto" : "smooth";
}
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<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const followingRef = useRef(followOutput);
const programmatic = useRef(false);
const timer = useRef<number | undefined>(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;
// 首次铺满内容、一次多出一屏以上直接跳到底,流式的小段增长才平滑跟随
const ro = new ResizeObserver(() => {
const vp = viewportRef.current;
if (!vp || !followOutput || !followingRef.current) return;
const gap = vp.scrollHeight - vp.scrollTop - vp.clientHeight;
scrollToEnd(followBehavior({ gap, viewport: vp.clientHeight, reduce }));
});
ro.observe(content);
return () => ro.disconnect();
}, [followOutput, reduce, scrollToEnd]);
useEffect(
() => () => {
if (timer.current) window.clearTimeout(timer.current);
},
[],
);
return (
<div data-slot="message-scroller" className={cn("relative min-h-0", className)} {...props}>
<section
ref={viewportRef}
aria-label={label}
// biome-ignore lint/a11y/noNoninteractiveTabindex: 滚动区要能用键盘聚焦后方向键滚动
tabIndex={0}
onScroll={onScroll}
onWheel={leaveLiveEdge}
onTouchStart={leaveLiveEdge}
onKeyDown={(e) => {
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,
)}
>
<div
ref={contentRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-busy={busy}
className={contentClassName}
>
{children}
</div>
</section>
{!following ? (
<button
type="button"
onClick={() => {
setFollowing(true);
scrollToEnd(reduce ? "auto" : "smooth");
}}
className="pg-float absolute bottom-3 left-1/2 z-10 inline-flex h-8 -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full px-3 text-meta font-medium text-foreground outline-none animate-in fade-in-0 slide-in-from-bottom-1 duration-base motion-reduce:animate-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown aria-hidden className="size-3.5" />
{jumpLabel}
{busy ? <span className="sr-only">(仍在输出)</span> : null}
</button>
) : null}
</div>
);
}