fix: stabilize workbench bottom follow scroll

This commit is contained in:
lyon
2026-06-19 02:45:53 +08:00
parent 940310433d
commit f095e61369
8 changed files with 455 additions and 59 deletions
@@ -61,6 +61,7 @@ const progressOnlyText = "PROGRESS_ONLY_SHOULD_NOT_RENDER_AS_FINAL";
const finalOnlyText = "FINAL_ONLY_SHOULD_RENDER";
let state = createScenarioState("baseline");
const sseClients = new Set<ServerResponse>();
const server = createServer((request, response) => {
void handleRequest(request, response).catch((error) => {
@@ -85,6 +86,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
state = createScenarioState(String(body.scenarioId ?? "baseline"));
return json(response, 200, stateSummary());
}
if (path === "/__e2e/scroll-follow/append" && method === "POST") {
const body = await readJson(request);
return json(response, 200, appendScrollFollowEvents(positiveInteger(body.count, 1)));
}
if (path === "/__e2e/gc-empty-sessions" && method === "POST") {
const body = await readJson(request);
return json(response, 200, gcEmptySessions(body));
@@ -95,7 +100,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
return json(response, payload.authenticated === true ? 200 : 401, payload);
}
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
if (path === "/v1/workbench/events" && method === "GET") return sse(response, url);
if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url);
if (path === "/v1/workbench/sessions" && method === "GET") {
await delay(state.sessionDelayMs);
return json(response, 200, workbenchSessionListPayload(url));
@@ -274,6 +279,11 @@ function createScenarioState(scenarioId: string): ScenarioState {
traces.trc_terminal_empty = terminalEmptyTrace();
}
if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces);
if (id === "scroll-follow-long-trace") {
const session = scrollFollowSession();
sessions.unshift(session);
traces.trc_scroll_follow = scrollFollowTrace(90);
}
if (id === "cross-session-late-events") {
sessions.unshift(crossSessionLateB(), crossSessionLateA());
traces.trc_late_A = crossSessionLateTraceA();
@@ -281,7 +291,9 @@ function createScenarioState(scenarioId: string): ScenarioState {
if (id === "deleted-session-deeplink") sessions.unshift(archivedDeletedSession());
if (id === "session-switch-empty-reload") sessions.push(emptySession());
if (id === "empty-session-gc") sessions.unshift(staleEmptyTtlSession(), freshEmptyTtlSession(), staleMessagedTtlSession());
const selectedSessionId = id === "deep-link" || id === "stale-nested-trace"
const selectedSessionId = id === "scroll-follow-long-trace"
? "ses_scroll_follow"
: id === "deep-link" || id === "stale-nested-trace"
? "ses_failed"
: id === "legacy-cnv-deeplink-canonical"
? "ses_running"
@@ -342,6 +354,82 @@ function markRunningProgressOnly(sessions: SessionRecord[], traces: Record<strin
});
}
function scrollFollowSession(): SessionRecord {
const now = new Date().toISOString();
const trace = scrollFollowTrace(90);
const history = Array.from({ length: 18 }, (_, index) => {
const seq = index + 1;
return seq % 2 === 0
? { id: `msg_scroll_history_agent_${seq}`, messageId: `msg_scroll_history_agent_${seq}`, role: "agent", title: "Code Agent", text: `历史回复 ${seq}\n\n用于撑开主工作区滚动高度。`, status: "completed", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: `turn_scroll_history_${seq}` }
: { id: `msg_scroll_history_user_${seq}`, messageId: `msg_scroll_history_user_${seq}`, role: "user", title: "用户", text: `历史问题 ${seq}:保持主工作区可滚动。`, status: "sent", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: `turn_scroll_history_${seq}` };
});
const messages = [
...history,
{ id: "msg_scroll_follow_user", messageId: "msg_scroll_follow_user", role: "user", title: "用户", text: "scroll follow long trace probe", status: "sent", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turnId: "turn_scroll_follow" },
{ id: "msg_scroll_follow_agent", messageId: "msg_scroll_follow_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turnId: "turn_scroll_follow", runnerTrace: trace }
];
return {
sessionId: "ses_scroll_follow",
threadId: "thr_scroll_follow",
status: "running",
lastTraceId: "trc_scroll_follow",
startedAt: now,
updatedAt: now,
messageCount: messages.length,
firstUserMessagePreview: "scroll follow long trace probe",
messages
};
}
function scrollFollowTrace(count: number): JsonRecord {
const events = Array.from({ length: count }, (_, index) => scrollFollowEvent(index + 1));
return { traceId: "trc_scroll_follow", status: "running", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: "turn_scroll_follow", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false };
}
function scrollFollowEvent(seq: number): JsonRecord {
const createdAt = new Date(Date.parse("2026-06-18T18:00:00.000Z") + seq * 1000).toISOString();
if (seq % 5 === 0) {
return { seq, sourceSeq: seq, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "running", message: `滚动跟随采样 assistant row ${seq}\n\n- markdown 行 ${seq}\n- append 后不能闪到顶部` };
}
return {
seq,
sourceSeq: seq,
createdAt,
label: "item/commandExecution:running",
type: "commandExecution",
status: "running",
itemId: `scroll-follow-${seq}`,
command: `printf 'scroll follow row ${seq}\\n'`,
stdout: `scroll-follow stdout ${seq}\nline ${seq} keeps the row tall enough for scroll sampling\n`
};
}
function appendScrollFollowEvents(count: number): JsonRecord {
if (state.scenarioId !== "scroll-follow-long-trace") return { ok: false, status: 409, error: { code: "scenario_mismatch", scenarioId: state.scenarioId } };
const trace = state.traces.trc_scroll_follow ?? scrollFollowTrace(0);
const events = Array.isArray(trace.events) ? [...trace.events as JsonRecord[]] : [];
const appended: JsonRecord[] = [];
for (let index = 0; index < count; index += 1) {
const seq = Number(events.at(-1)?.seq ?? events.length) + 1;
const event = scrollFollowEvent(seq);
events.push(event);
appended.push(event);
}
const nextTrace = { ...trace, status: "running", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false };
state.traces.trc_scroll_follow = nextTrace;
const session = sessionById("ses_scroll_follow");
if (session) {
session.status = "running";
session.updatedAt = new Date().toISOString();
session.messages = (session.messages ?? []).map((message) => message.role === "agent" && message.traceId === "trc_scroll_follow" ? { ...message, status: "running", runnerTrace: nextTrace } : message);
}
for (const event of appended) {
broadcastSse("workbench.trace.event", { type: "trace.event", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", event, snapshot: nextTrace });
}
broadcastSse("workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turn: turnPayload("trc_scroll_follow") });
return { ok: true, status: "appended", appended: appended.length, eventCount: events.length };
}
function createManualSession(body: JsonRecord): SessionRecord {
const now = new Date().toISOString();
const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36);
@@ -720,8 +808,10 @@ function workbenchTracePayload(traceId: string, url: URL): JsonRecord {
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSinceSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention };
}
function sse(response: ServerResponse, url: URL): void {
function sse(request: IncomingMessage, response: ServerResponse, url: URL): void {
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" });
sseClients.add(response);
request.on("close", () => sseClients.delete(response));
writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId") });
if (state.scenarioId === "cross-session-late-events") {
const scenarioId = state.scenarioId;
@@ -747,6 +837,10 @@ function sse(response: ServerResponse, url: URL): void {
}, state.scenarioId === "progress-only-final-response" ? 5_000 : 350);
}
function broadcastSse(eventName: string, payload: JsonRecord): void {
for (const client of Array.from(sseClients)) writeSse(client, eventName, payload);
}
function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void {
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import type { RunnerTrace } from "@/types";
import LoadingState from "@/components/common/LoadingState.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { traceDisplayRows, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null }>();
const listRef = ref<HTMLOListElement | null>(null);
const following = ref(true);
const expanded = ref(false);
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useBottomFollowScroll(listRef, { threshold: 24 });
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
@@ -22,28 +23,15 @@ const emptyTraceLabel = computed(() => {
const toolPreviewLimit = 140;
let programmaticToggleTarget: boolean | null = null;
watch(() => [eventCount.value, props.trace?.status, following.value, expanded.value], async () => {
if (!following.value || !expanded.value) return;
await nextTick();
jumpToBottom();
watch(() => [eventCount.value, props.trace?.status, readableRows.value.length, expanded.value], () => {
if (!expanded.value) return;
void keepBottomAfterUpdate();
});
watch(() => [props.trace?.traceId, props.storageKey, props.defaultExpanded, props.autoExpanded] as const, () => {
setExpandedProgrammatically(readStoredExpanded() ?? props.autoExpanded ?? props.defaultExpanded ?? false);
}, { immediate: true });
function jumpToBottom(): void {
const list = listRef.value;
if (!list) return;
list.scrollTop = list.scrollHeight;
}
function onScroll(): void {
const list = listRef.value;
if (!list) return;
following.value = list.scrollTop + list.clientHeight >= list.scrollHeight - 24;
}
function rowKey(row: TraceEventRow, index: number): string {
return `${row.rowId}-${row.seq ?? "no-seq"}-${index}`;
}
@@ -52,6 +40,7 @@ function onDisclosureToggle(event: Event): void {
const target = event.currentTarget;
if (!(target instanceof HTMLDetailsElement)) return;
expanded.value = target.open;
if (target.open) scrollToBottom();
if (programmaticToggleTarget === target.open) {
programmaticToggleTarget = null;
return;
@@ -12,6 +12,7 @@ import MessageActions from "@/components/workbench/MessageActions.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchStore } from "@/stores/workbench";
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
@@ -21,7 +22,7 @@ import { traceLifecycleExpanded } from "./trace-lifecycle";
const workbench = useWorkbenchStore();
const clipboard = useClipboard();
const panelRef = ref<HTMLElement | null>(null);
const following = ref(true);
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 });
const detailMessageId = ref<string | null>(null);
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
@@ -36,9 +37,7 @@ const scrollSignature = computed(() => workbench.activeMessages.map((message) =>
].join(":")));
watch(scrollSignature, async () => {
if (!following.value) return;
await nextTick();
scrollToBottom();
await keepBottomAfterUpdate();
acknowledgeVisibleMessages();
});
@@ -89,18 +88,6 @@ function messageDiagnosticText(message: ChatMessage): string | null {
return "消息内容缺失:Workbench read model 未返回 user text。";
}
function onPanelScroll(): void {
const panel = panelRef.value;
if (!panel) return;
following.value = panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 56;
}
function scrollToBottom(): void {
const panel = panelRef.value;
if (!panel) return;
panel.scrollTop = panel.scrollHeight;
}
function acknowledgeVisibleMessages(): void {
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading });
}
@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import type { ChatMessage, RunnerTrace, TraceEvent } from "@/types";
import { firstNonEmptyString } from "@/utils";
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { traceEventBody, traceEventLabel, traceEventMeta } from "@/components/workbench/message-rendering";
import { traceDisplayRows, traceNoiseEventCount } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
const props = defineProps<{ message: ChatMessage }>();
const rawListRef = ref<HTMLOListElement | null>(null);
const following = ref(true);
const rawMode = ref(false);
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom, toggleFollowing } = useBottomFollowScroll(rawListRef, { threshold: 24 });
const trace = computed(() => props.message.runnerTrace ?? null);
const events = computed(() => Array.isArray(trace.value?.events) ? trace.value.events : []);
@@ -33,23 +34,10 @@ const summaryItems = computed(() => {
});
watch(() => [eventCount.value, rawMode.value, following.value] as const, async () => {
if (!rawMode.value || !following.value) return;
await nextTick();
jumpToBottom();
if (!rawMode.value) return;
await keepBottomAfterUpdate();
});
function jumpToBottom(): void {
const list = rawListRef.value;
if (!list) return;
list.scrollTop = list.scrollHeight;
}
function onScroll(): void {
const list = rawListRef.value;
if (!list) return;
following.value = list.scrollTop + list.clientHeight >= list.scrollHeight - 24;
}
function eventKey(event: TraceEvent, index: number): string {
return `${event.ts ?? "no-ts"}-${event.label ?? event.type ?? event.kind ?? "event"}-${index}`;
}
@@ -78,11 +66,11 @@ function recordValue(value: unknown): Record<string, unknown> | null {
<p v-if="trace.eventsCompacted" class="trace-warning">当前 result 返回被压缩前端会自动通过 trace 入口补齐完整事件完成前不要把压缩窗口当完整 trace</p>
<p v-if="noiseCount > 0" class="trace-warning">已聚合隐藏 {{ noiseCount }} backend/chunk/token 噪声事件原始 payload 仍可在原始事件中审计</p>
<div class="trace-meta-actions">
<button class="btn btn-secondary btn-sm" type="button" @click="following = !following">{{ following ? "跟随" : "暂停" }}</button>
<button class="btn btn-secondary btn-sm" type="button" @click="jumpToBottom">到底部</button>
<button class="btn btn-secondary btn-sm" type="button" @click="toggleFollowing">{{ following ? "跟随" : "暂停" }}</button>
<button class="btn btn-secondary btn-sm" type="button" @click="scrollToBottom">到底部</button>
<button class="btn btn-secondary btn-sm" type="button" @click="rawMode = !rawMode">{{ rawMode ? "隐藏原始事件" : "原始事件" }}</button>
</div>
<ol v-if="rawMode" ref="rawListRef" class="trace-raw-list" @scroll="onScroll">
<ol v-if="rawMode" ref="rawListRef" class="trace-raw-list" :data-following="following ? 'true' : 'false'" @scroll="onScroll">
<li v-for="(event, index) in events" :key="eventKey(event, index)" class="trace-raw-row" :data-event-status="event.status || 'pending'">
<code>{{ traceEventLabel(event) }}</code>
<span>{{ traceEventBody(event) }}</span>
@@ -0,0 +1,144 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
export interface BottomFollowScrollOptions {
threshold?: number;
initialFollowing?: boolean;
}
export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T | null>, options: BottomFollowScrollOptions = {}) {
const threshold = options.threshold ?? 24;
const following = ref(options.initialFollowing ?? true);
let animationFrame: number | null = null;
let releaseProgrammaticFrame: number | null = null;
let programmaticScroll = false;
let observedElement: T | null = null;
let mutationObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
watch(containerRef, (element) => {
installObservers(element);
if (element && following.value) scheduleBottomAlignment();
}, { flush: "post" });
onMounted(() => {
installObservers(containerRef.value);
if (following.value) scheduleBottomAlignment();
});
onBeforeUnmount(() => {
disconnectObservers();
cancelAnimationFrameIfNeeded();
if (releaseProgrammaticFrame !== null) window.cancelAnimationFrame(releaseProgrammaticFrame);
});
function onScroll(): void {
if (programmaticScroll) return;
syncFollowingFromPosition();
}
async function keepBottomAfterUpdate(): Promise<void> {
if (!following.value) return;
await nextTick();
scheduleBottomAlignment();
}
function scrollToBottom(): void {
following.value = true;
scheduleBottomAlignment();
}
function resumeFollowing(): void {
scrollToBottom();
}
function pauseFollowing(): void {
following.value = false;
}
function toggleFollowing(): void {
if (following.value) pauseFollowing();
else resumeFollowing();
}
function syncFollowingFromPosition(): void {
const element = containerRef.value;
if (!element) return;
following.value = distanceToBottom(element) <= threshold;
}
function scheduleBottomAlignment(): void {
if (animationFrame !== null) return;
animationFrame = window.requestAnimationFrame(() => {
animationFrame = null;
if (!following.value) return;
const element = containerRef.value;
if (!element) return;
alignBottom(element);
});
}
function alignBottom(element: HTMLElement): void {
markProgrammaticScroll();
element.scrollTo({ top: maxScrollTop(element), behavior: "auto" });
}
function markProgrammaticScroll(): void {
programmaticScroll = true;
if (releaseProgrammaticFrame !== null) window.cancelAnimationFrame(releaseProgrammaticFrame);
releaseProgrammaticFrame = window.requestAnimationFrame(() => {
releaseProgrammaticFrame = null;
programmaticScroll = false;
syncFollowingFromPosition();
});
}
function installObservers(element: T | null): void {
if (observedElement === element) return;
disconnectObservers();
observedElement = element;
if (!element) return;
const onContentChanged = () => {
refreshResizeTargets(element);
if (following.value) scheduleBottomAlignment();
};
mutationObserver = new MutationObserver(onContentChanged);
mutationObserver.observe(element, { childList: true, subtree: true, characterData: true });
resizeObserver = new ResizeObserver(() => {
if (following.value) scheduleBottomAlignment();
});
refreshResizeTargets(element);
}
function refreshResizeTargets(element: HTMLElement): void {
if (!resizeObserver) return;
resizeObserver.disconnect();
resizeObserver.observe(element);
for (const child of Array.from(element.children)) resizeObserver.observe(child);
}
function disconnectObservers(): void {
mutationObserver?.disconnect();
resizeObserver?.disconnect();
mutationObserver = null;
resizeObserver = null;
observedElement = null;
}
function cancelAnimationFrameIfNeeded(): void {
if (animationFrame === null) return;
window.cancelAnimationFrame(animationFrame);
animationFrame = null;
}
function distanceToBottom(element: HTMLElement): number {
return maxScrollTop(element) - element.scrollTop;
}
function maxScrollTop(element: HTMLElement): number {
return Math.max(0, element.scrollHeight - element.clientHeight);
}
return { following, keepBottomAfterUpdate, onScroll, pauseFollowing, resumeFollowing, scrollToBottom, toggleFollowing };
}
+9 -1
View File
@@ -766,7 +766,13 @@
.conversation-panel {
padding: 8px;
scroll-behavior: smooth;
scroll-behavior: auto;
}
.conversation-panel[data-following="true"],
.trace-timeline[data-following="true"] ol,
.trace-raw-list[data-following="true"] {
overflow-anchor: none;
}
.conversation-empty-hint {
@@ -1096,6 +1102,7 @@
margin: 0;
overflow: auto;
padding: 0;
scroll-behavior: auto;
list-style: none;
}
@@ -1189,6 +1196,7 @@
margin: 0;
overflow: auto;
padding: 0;
scroll-behavior: auto;
list-style: none;
}
@@ -0,0 +1,186 @@
import type { Locator, Page, TestInfo } from "@playwright/test";
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors } from "../fixtures/selectors";
interface ScrollSample {
t: number;
scrollTop: number;
clientHeight: number;
scrollHeight: number;
distanceToBottom: number;
following: string | null;
firstVisibleText: string | null;
lastVisibleText: string | null;
}
test.describe("Workbench bottom-follow scrolling", () => {
test.use({ scenarioId: "scroll-follow-long-trace" });
test("Trace, main conversation, and raw Trace lists stay bottom-pinned without top flashes", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/ses_scroll_follow");
const panel = page.locator(selectors.conversationList);
const trace = page.locator(`${selectors.traceTimeline}[data-status="running"]`).last();
await expect(trace).toBeVisible();
await openTrace(trace);
const traceList = trace.locator("ol");
await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(70);
await page.locator(`${selectors.messageCard}[data-role="agent"] .message-detail-button`).last().click();
await expect(page.locator(selectors.messageDetailDialog)).toBeVisible();
await page.locator(selectors.messageDetailDialog).getByRole("button", { name: "原始事件" }).click();
const rawList = page.locator(".trace-raw-list");
await expect.poll(async () => await rawList.locator("li").count()).toBeGreaterThan(70);
await waitUntilScrollable(panel);
await waitUntilScrollable(traceList);
await waitUntilScrollable(rawList);
await scrollToBottom(panel);
await scrollToBottom(traceList);
await scrollToBottom(rawList);
await expect(panel).toHaveAttribute("data-following", "true");
await expect(trace).toHaveAttribute("data-following", "true");
await expect(rawList).toHaveAttribute("data-following", "true");
await saveScreenshot(page, testInfo, "scroll-follow-bottom-before-append");
const samplesPromise = collectScrollTimelines(page, {
panel: selectors.conversationList,
trace: `${selectors.traceTimeline}[data-status="running"] ol`,
raw: ".trace-raw-list"
}, 1800);
await appendEvents(page, 4);
await page.waitForTimeout(250);
await appendEvents(page, 4);
await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(90);
await expect.poll(async () => await rawList.locator("li").count()).toBeGreaterThan(90);
const samples = await samplesPromise;
const panelSamples = samples.panel ?? [];
const traceSamples = samples.trace ?? [];
const rawSamples = samples.raw ?? [];
await attachTimeline(testInfo, "conversation-panel-scroll-timeline", panelSamples);
await attachTimeline(testInfo, "trace-list-scroll-timeline", traceSamples);
await attachTimeline(testInfo, "raw-trace-scroll-timeline", rawSamples);
assertNoTopFlash(panelSamples, "conversation-panel", 64);
assertNoTopFlash(traceSamples, "trace-timeline", 32);
assertNoTopFlash(rawSamples, "trace-raw-list", 32);
await saveScreenshot(page, testInfo, "scroll-follow-bottom-after-append");
await scrollToMiddle(traceList);
await expect(trace).toHaveAttribute("data-following", "false");
const beforePausedAppend = await readScroll(traceList);
await appendEvents(page, 3);
await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(100);
const afterPausedAppend = await readScroll(traceList);
expect(Math.abs(afterPausedAppend.scrollTop - beforePausedAppend.scrollTop)).toBeLessThanOrEqual(48);
expect(afterPausedAppend.distanceToBottom).toBeGreaterThan(96);
await scrollToBottom(traceList);
await expect(trace).toHaveAttribute("data-following", "true");
await appendEvents(page, 2);
await expect.poll(async () => (await readScroll(traceList)).distanceToBottom).toBeLessThanOrEqual(32);
await saveScreenshot(page, testInfo, "scroll-follow-user-pause-and-resume");
});
});
async function openTrace(trace: Locator): Promise<void> {
const details = trace.locator("details.trace-disclosure");
const open = await details.evaluate((node) => (node as HTMLDetailsElement).open);
if (!open) await trace.locator("summary.trace-disclosure-summary").click();
await expect(details).toHaveJSProperty("open", true);
}
async function appendEvents(page: Page, count: number): Promise<void> {
const response = await page.request.post("/__e2e/scroll-follow/append", { data: { count } });
expect(response.ok()).toBeTruthy();
const payload = await response.json() as { ok?: boolean; status?: string; eventCount?: number };
expect(payload.ok).toBe(true);
expect(payload.status).toBe("appended");
}
async function waitUntilScrollable(locator: Locator): Promise<void> {
await expect.poll(async () => await locator.evaluate((node) => {
const element = node as HTMLElement;
return element.scrollHeight > element.clientHeight + 24;
})).toBe(true);
}
async function scrollToBottom(locator: Locator): Promise<void> {
await locator.evaluate((node) => {
const element = node as HTMLElement;
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
}
async function scrollToMiddle(locator: Locator): Promise<void> {
await locator.evaluate((node) => {
const element = node as HTMLElement;
element.scrollTop = Math.max(0, Math.floor((element.scrollHeight - element.clientHeight) / 2));
element.dispatchEvent(new Event("scroll"));
});
}
async function readScroll(locator: Locator): Promise<Pick<ScrollSample, "scrollTop" | "clientHeight" | "scrollHeight" | "distanceToBottom">> {
return await locator.evaluate((node) => {
const element = node as HTMLElement;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
return { scrollTop: element.scrollTop, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, distanceToBottom: maxScrollTop - element.scrollTop };
});
}
async function collectScrollTimelines(page: Page, targets: Record<string, string>, durationMs: number): Promise<Record<string, ScrollSample[]>> {
return await page.evaluate(({ selectors: targetSelectors, duration }) => new Promise<Record<string, ScrollSample[]>>((resolve) => {
const entries = Object.entries(targetSelectors).map(([name, selector]) => [name, document.querySelector(selector) as HTMLElement | null] as const);
const samples = Object.fromEntries(entries.map(([name]) => [name, []])) as Record<string, ScrollSample[]>;
const startedAt = performance.now();
const visibleTexts = (element: HTMLElement): { first: string | null; last: string | null } => {
const containerRect = element.getBoundingClientRect();
const candidates = Array.from(element.querySelectorAll(":scope > li, :scope > article, .trace-render-row, .trace-raw-row")) as HTMLElement[];
const visible = candidates.filter((item) => {
const rect = item.getBoundingClientRect();
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
}).map((item) => {
const text = String(item.textContent ?? "").replace(/\s+/gu, " ").trim();
return text ? text.slice(0, 120) : null;
}).filter((value): value is string => Boolean(value));
return { first: visible[0] ?? null, last: visible.at(-1) ?? null };
};
const collect = () => {
const now = performance.now();
for (const [name, element] of entries) {
if (!element) continue;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
const visible = visibleTexts(element);
samples[name]?.push({
t: Math.round(now - startedAt),
scrollTop: Math.round(element.scrollTop),
clientHeight: Math.round(element.clientHeight),
scrollHeight: Math.round(element.scrollHeight),
distanceToBottom: Math.round(maxScrollTop - element.scrollTop),
following: element.dataset.following ?? element.closest("[data-following]")?.getAttribute("data-following") ?? null,
firstVisibleText: visible.first,
lastVisibleText: visible.last
});
}
if (now - startedAt >= duration) resolve(samples);
else window.requestAnimationFrame(collect);
};
window.requestAnimationFrame(collect);
}), { selectors: targets, duration: durationMs });
}
async function attachTimeline(testInfo: TestInfo, name: string, samples: ScrollSample[]): Promise<void> {
await testInfo.attach(`${name}.json`, { body: JSON.stringify(samples, null, 2), contentType: "application/json" });
}
function assertNoTopFlash(samples: ScrollSample[], name: string, bottomThreshold: number): void {
const scrollable = samples.filter((sample) => sample.scrollHeight > sample.clientHeight + 24);
expect(scrollable.length, `${name} did not produce scrollable samples`).toBeGreaterThan(4);
const maxScrollTop = Math.max(...scrollable.map((sample) => sample.scrollTop));
expect(maxScrollTop, `${name} never sampled away from the top`).toBeGreaterThan(40);
const topFlashes = scrollable.filter((sample, index) => index > 1 && maxScrollTop > 80 && sample.scrollTop <= 4);
expect(topFlashes, `${name} flashed to top: ${JSON.stringify(topFlashes.slice(0, 3))}`).toEqual([]);
const final = scrollable.at(-1);
expect(final?.distanceToBottom ?? Number.POSITIVE_INFINITY, `${name} final sample was not bottom-pinned`).toBeLessThanOrEqual(bottomThreshold);
}
@@ -10,7 +10,7 @@ test("TraceTimeline renders readable rows and hides backend noise", async ({ pag
await expect(page.locator(`${selectors.traceRow}[data-row-kind="tool"]`)).toContainText("commandExecution");
await expect(page.locator(`${selectors.traceRow}[data-terminal="true"]`)).toContainText("构建和 Trace 渲染检查完成");
await expect(page.locator(selectors.conversationList)).not.toContainText("token_count");
await page.locator(".message-detail-button").first().click();
await page.locator(`${selectors.messageCard}[data-role="agent"] .message-detail-button`).first().click();
await expect(page.locator(selectors.messageDetailDialog)).toContainText("trc_completed");
await expect(page.locator(selectors.messageDetailDialog)).toContainText("ses_completed");
await saveScreenshot(page, testInfo, "trace-rendering-detail");