From 0966f382b675e628ef5cf1fb50f449c0a5678fe1 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 18 Aug 2026 10:32:35 +0800 Subject: [PATCH] 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. --- .../assistant-editor/debug-auto-test.tsx | 107 +++++++++++++++++- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/assistant-editor/debug-auto-test.tsx b/frontend/src/components/assistant-editor/debug-auto-test.tsx index a4bfaf4..193933f 100644 --- a/frontend/src/components/assistant-editor/debug-auto-test.tsx +++ b/frontend/src/components/assistant-editor/debug-auto-test.tsx @@ -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(null); + const contentRef = useRef(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 ( +
+
+
{children}
+
+ {!isFollowing && ( + + )} +
+ ); +} + 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 ( -
+ 正在执行第 1 轮固定输入…
- + ); } return ( @@ -382,7 +478,10 @@ export function AutoTestLivePanel({ state.phase === "running" && state.currentTurn >= state.maxTurns; return ( -
+
)}
-
+ ); }