- 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]>
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
// 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>
|
||
);
|
||
}
|