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:
@@ -11,6 +11,7 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FlaskConical,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RUN_POLL_MS = 1000;
|
||||
const AUTO_FOLLOW_BOTTOM_THRESHOLD_PX = 32;
|
||||
|
||||
export type AutoTestPhase =
|
||||
| "idle"
|
||||
@@ -253,6 +255,84 @@ function CasePicker({
|
||||
|
||||
// ─── 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({
|
||||
testCase,
|
||||
state,
|
||||
@@ -353,10 +433,26 @@ export function AutoTestLivePanel({
|
||||
|
||||
const turns = state.runCase?.turns ?? [];
|
||||
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 (startup?.completed) {
|
||||
return (
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<AutoFollowViewport
|
||||
key={autoFollowResetKey}
|
||||
followKey={autoFollowKey}
|
||||
>
|
||||
<AutoTestTimeline
|
||||
startup={startup}
|
||||
turns={[]}
|
||||
@@ -366,7 +462,7 @@ export function AutoTestLivePanel({
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
正在执行第 1 轮固定输入…
|
||||
</div>
|
||||
</div>
|
||||
</AutoFollowViewport>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -382,7 +478,10 @@ export function AutoTestLivePanel({
|
||||
state.phase === "running" && state.currentTurn >= state.maxTurns;
|
||||
|
||||
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">
|
||||
<AutoTestTimeline
|
||||
startup={startup}
|
||||
@@ -424,7 +523,7 @@ export function AutoTestLivePanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AutoFollowViewport>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user