Files
UHD_Media_Tool/apps/web/app/components/agentui/message-scroller.tsx
T
rootandClaude Opus 5.5 8352e5e881 feat(web): W4-F rebuild agent conversation and drawer with agentui components
- Port agentui (MIT, ashish200729/agentui@b2b752b) Message/MessageBubble/MessageScroller,
  StreamingResponse, ToolResult/ToolApproval, TodoList, ApprovalCard, CodeBlock/FileDiff,
  Citations, PromptInput into components/agentui; drop motion/shiki/Next, switch colors,
  type scale, radii, durations and easing to semantic tokens, honour reduced motion
- Conversation: streaming cursor, collapsible thinking, tool cards with kind/scope/risk/
  idempotency badges and audit idempotency key, prominent server-rejection state for both
  whitelist rejections and unauthorized errors, compaction markers, budget meters with
  over-limit state, approval cards (admin session only), two-column final report
  (model said vs system verified with task/record links), follow-scroll with jump-to-bottom
- Composer: Enter send / Shift+Enter newline, stop = abort (confirmed), append,
  execute/plan_only switch, "execute this plan" from finished plan_only runs
- Session list sidebar with status filter and usage; reconnect/error banners with retry
- Drawer: route-derived context (entity/site/task), minimize to a live mini dock
- Fix stream refetch race when the run switches from loading to finished

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:08:17 +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>
);
}