Files
UHD_Media_Tool/apps/web/app/components/agentui/message-scroller.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

180 lines
6.4 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;
}
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;
// 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随
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 (
<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>
);
}