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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user