feat: implement auto-follow viewport for live test results

- Added AutoFollowViewport component to automatically scroll to the latest test results.
- Integrated auto-follow functionality into AutoTestLivePanel for improved user experience during test execution.
- Introduced logic to manage following state and handle scroll events effectively.
This commit is contained in:
Xin Wang
2026-08-18 10:32:35 +08:00
parent d003ee77a5
commit 0966f382b6
@@ -11,6 +11,7 @@
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ArrowDown,
Check, Check,
ChevronDown, ChevronDown,
FlaskConical, FlaskConical,
@@ -57,6 +58,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const RUN_POLL_MS = 1000; const RUN_POLL_MS = 1000;
const AUTO_FOLLOW_BOTTOM_THRESHOLD_PX = 32;
export type AutoTestPhase = export type AutoTestPhase =
| "idle" | "idle"
@@ -253,6 +255,84 @@ function CasePicker({
// ─── Result panel ─────────────────────────────────────────────────────────── // ─── Result panel ───────────────────────────────────────────────────────────
function AutoFollowViewport({
children,
followKey,
}: {
children: React.ReactNode;
followKey: string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isFollowingRef = useRef(true);
const [isFollowing, setIsFollowing] = useState(true);
const updateFollowing = useCallback((next: boolean) => {
isFollowingRef.current = next;
setIsFollowing((current) => (current === next ? current : next));
}, []);
const scrollToLatest = useCallback(() => {
const viewport = scrollRef.current;
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
}, []);
const handleScroll = useCallback(() => {
const viewport = scrollRef.current;
if (!viewport) return;
const distanceFromBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
updateFollowing(distanceFromBottom <= AUTO_FOLLOW_BOTTOM_THRESHOLD_PX);
}, [updateFollowing]);
useEffect(() => {
if (!isFollowingRef.current) return;
const frame = window.requestAnimationFrame(scrollToLatest);
return () => window.cancelAnimationFrame(frame);
}, [followKey, scrollToLatest]);
// Runtime cards and images can grow after the test snapshot is rendered.
useEffect(() => {
const content = contentRef.current;
if (!content) return;
const observer = new ResizeObserver(() => {
if (isFollowingRef.current) scrollToLatest();
});
observer.observe(content);
return () => observer.disconnect();
}, [scrollToLatest]);
const resumeFollowing = useCallback(() => {
updateFollowing(true);
scrollToLatest();
}, [scrollToLatest, updateFollowing]);
return (
<div className="relative flex min-h-0 flex-1">
<div
ref={scrollRef}
onScroll={handleScroll}
className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-5 py-4"
>
<div ref={contentRef}>{children}</div>
</div>
{!isFollowing && (
<Button
type="button"
size="sm"
variant="secondary"
onClick={resumeFollowing}
className="absolute bottom-4 left-1/2 h-8 -translate-x-1/2 gap-1.5 rounded-full border border-hairline bg-background/95 px-3 text-xs shadow-md backdrop-blur"
>
<ArrowDown size={13} />
回到最新
</Button>
)}
</div>
);
}
export function AutoTestLivePanel({ export function AutoTestLivePanel({
testCase, testCase,
state, state,
@@ -353,10 +433,26 @@ export function AutoTestLivePanel({
const turns = state.runCase?.turns ?? []; const turns = state.runCase?.turns ?? [];
const startup = state.runCase?.startup; const startup = state.runCase?.startup;
const autoFollowKey = [
state.phase,
startup?.completed ? 1 : 0,
startup?.assistantOutputs.length ?? 0,
startup?.runtimeRecords?.length ?? 0,
...turns.map(
(turn) =>
`${turn.id}:${turn.runtimeRecords?.length ?? 0}:${turn.toolCalls.length}:${turn.evaluations.length}`,
),
state.runCase?.overallCriteria.length ?? 0,
].join("|");
const autoFollowResetKey = state.run?.id ?? state.selectedId ?? "auto-test";
if (turns.length === 0) { if (turns.length === 0) {
if (startup?.completed) { if (startup?.completed) {
return ( return (
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-5 py-4"> <AutoFollowViewport
key={autoFollowResetKey}
followKey={autoFollowKey}
>
<AutoTestTimeline <AutoTestTimeline
startup={startup} startup={startup}
turns={[]} turns={[]}
@@ -366,7 +462,7 @@ export function AutoTestLivePanel({
<Loader2 size={13} className="animate-spin" /> <Loader2 size={13} className="animate-spin" />
正在执行第 1 轮固定输入… 正在执行第 1 轮固定输入…
</div> </div>
</div> </AutoFollowViewport>
); );
} }
return ( return (
@@ -382,7 +478,10 @@ export function AutoTestLivePanel({
state.phase === "running" && state.currentTurn >= state.maxTurns; state.phase === "running" && state.currentTurn >= state.maxTurns;
return ( return (
<div className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"> <AutoFollowViewport
key={autoFollowResetKey}
followKey={autoFollowKey}
>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<AutoTestTimeline <AutoTestTimeline
startup={startup} startup={startup}
@@ -424,7 +523,7 @@ export function AutoTestLivePanel({
</div> </div>
)} )}
</div> </div>
</div> </AutoFollowViewport>
); );
} }