fix: stabilize workbench bottom follow scroll
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user