fix: preserve trace scroll user control

This commit is contained in:
lyon
2026-05-25 15:47:43 +08:00
parent 4eaf31b58f
commit 2759e823e8
4 changed files with 290 additions and 9 deletions
+155 -8
View File
@@ -104,6 +104,8 @@ const LEFT_SIDEBAR_MAX_WIDTH = 180;
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728;
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
const RIGHT_SIDEBAR_MAX_WIDTH = 740;
const SCROLL_BOTTOM_PIN_PX = 24;
const SCROLL_USER_ACTIVITY_MS = 700;
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
let rpcSequence = 0;
@@ -180,8 +182,11 @@ const state = {
chatMessages: [],
traceStreams: new Map(),
traceDetailsOpen: new Map(),
conversationRenderVersion: 0,
conversationScrollUserActiveUntil: 0,
conversationScrollPosition: { top: 0, left: 0 },
traceScrollPositions: new Map(),
traceScrollUserActiveUntil: new Map(),
canceledTraces: new Set(),
currentRequest: null,
sessionStatus: null,
@@ -225,6 +230,7 @@ initLiveBuildOverlay();
initWorkbenchLogout(el.logoutButton);
initM3Control();
initGateControls();
installWorkbenchTestHooks();
renderStaticWorkbench();
renderProbePending();
renderCodeAgentSummary();
@@ -794,8 +800,11 @@ function initCommandBar() {
for (const close of state.traceStreams.values()) close();
state.traceStreams.clear();
state.traceDetailsOpen.clear();
state.conversationRenderVersion += 1;
state.conversationScrollUserActiveUntil = 0;
state.conversationScrollPosition = { top: 0, left: 0 };
state.traceScrollPositions.clear();
state.traceScrollUserActiveUntil.clear();
state.chatMessages = [];
state.conversationId = null;
state.sessionId = null;
@@ -2043,6 +2052,7 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res
}
function renderConversation() {
const renderVersion = ++state.conversationRenderVersion;
captureConversationScrollPosition();
captureTraceScrollPositions();
const introMessages = [
@@ -2058,28 +2068,45 @@ function renderConversation() {
restoreConversationScrollPosition();
restoreTraceScrollPositions();
window.requestAnimationFrame(() => {
restoreConversationScrollPosition();
restoreTraceScrollPositions();
if (renderVersion !== state.conversationRenderVersion) return;
restoreConversationScrollPosition({ deferred: true });
restoreTraceScrollPositions(el.conversationList, { deferred: true });
});
}
function initConversationScrollMemory() {
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true });
}
el.conversationList.addEventListener("keydown", (event) => {
if (isScrollIntentKey(event.key)) markConversationScrollIntent();
});
el.conversationList.addEventListener("scroll", () => {
captureConversationScrollPosition();
}, { passive: true });
}
function markConversationScrollIntent() {
state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS;
}
function isScrollIntentKey(key) {
return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key);
}
function captureConversationScrollPosition() {
state.conversationScrollPosition = {
top: el.conversationList.scrollTop,
left: el.conversationList.scrollLeft
left: el.conversationList.scrollLeft,
bottomGap: scrollBottomGap(el.conversationList)
};
}
function restoreConversationScrollPosition() {
function restoreConversationScrollPosition(options = {}) {
if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return;
const position = state.conversationScrollPosition;
if (!position) return;
el.conversationList.scrollTop = Math.min(position.top, Math.max(0, el.conversationList.scrollHeight - el.conversationList.clientHeight));
el.conversationList.scrollTop = scrollTopForPosition(el.conversationList, position);
el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth));
}
@@ -2093,19 +2120,41 @@ function rememberTraceScrollPosition(traceUiKey, list) {
if (!traceUiKey || !list) return;
state.traceScrollPositions.set(traceUiKey, {
top: list.scrollTop,
left: list.scrollLeft
left: list.scrollLeft,
bottomGap: scrollBottomGap(list)
});
}
function restoreTraceScrollPositions(root = el.conversationList) {
function restoreTraceScrollPositions(root = el.conversationList, options = {}) {
for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) {
if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue;
const position = state.traceScrollPositions.get(list.dataset.traceUiKey);
if (!position) continue;
list.scrollTop = Math.min(position.top, Math.max(0, list.scrollHeight - list.clientHeight));
list.scrollTop = scrollTopForPosition(list, position);
list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth));
}
}
function scrollBottomGap(element) {
return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop);
}
function scrollTopForPosition(element, position) {
const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop;
return Math.min(position.top, maxTop);
}
function markTraceScrollIntent(traceUiKey) {
if (!traceUiKey) return;
state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS);
}
function isTraceScrollUserActive(traceUiKey) {
if (!traceUiKey) return false;
return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0);
}
function renderHardwareStatus(m3Status) {
syncHardwareTabs();
const status = m3Status ?? sourceFallbackM3Status();
@@ -4158,6 +4207,12 @@ function messageTracePanel(message) {
list.className = "message-trace-events";
if (traceUiKey) {
list.dataset.traceUiKey = traceUiKey;
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true });
}
list.addEventListener("keydown", (event) => {
if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey);
});
list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true });
}
const events = Array.isArray(trace?.events) ? trace.events : [];
@@ -4222,6 +4277,98 @@ function renderTraceEventList(list, rows) {
}
}
function installWorkbenchTestHooks() {
if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return;
window.__hwlabWorkbenchTestHooks = {
seedTraceMessage,
appendTraceEvents,
traceScrollMetrics,
setTraceScrollTop,
setConversationScrollTop
};
}
function isLocalWorkbenchTestHost() {
return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname);
}
function seedTraceMessage(options = {}) {
const traceId = options.traceId ?? "trc_scroll_contract";
const messageId = options.messageId ?? "msg_scroll_contract";
const count = Math.max(1, Number(options.count ?? 80));
const events = Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1));
state.chatMessages = [{
id: messageId,
role: "agent",
title: "Code Agent 处理中",
text: "Trace scroll contract fixture",
status: "running",
traceId,
runnerTrace: {
traceId,
events,
eventCount: events.length,
lastEvent: events.at(-1),
waitingFor: "turn/completed"
}
}];
renderConversation();
}
function appendTraceEvents(count = 1) {
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
if (!message) return;
const trace = message.runnerTrace;
const start = Array.isArray(trace.events) ? trace.events.length : 0;
const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1));
trace.events = [...trace.events, ...additions];
trace.eventCount = trace.events.length;
trace.lastEvent = trace.events.at(-1);
renderConversation();
}
function testTraceEvent(traceId, seq) {
return {
traceId,
label: "trace:scroll-contract",
type: "trace",
status: "observed",
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`,
waitingFor: "turn/completed"
};
}
function traceScrollMetrics() {
const conversation = el.conversationList;
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
return {
conversationTop: conversation.scrollTop,
conversationBottomGap: scrollBottomGap(conversation),
traceTop: list?.scrollTop ?? null,
traceBottomGap: list ? scrollBottomGap(list) : null,
traceScrollHeight: list?.scrollHeight ?? null,
traceClientHeight: list?.clientHeight ?? null,
traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0
};
}
function setTraceScrollTop(top, { user = true } = {}) {
const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]");
if (!list) return traceScrollMetrics();
if (user) markTraceScrollIntent(list.dataset.traceUiKey);
list.scrollTop = top;
rememberTraceScrollPosition(list.dataset.traceUiKey, list);
return traceScrollMetrics();
}
function setConversationScrollTop(top, { user = true } = {}) {
if (user) markConversationScrollIntent();
el.conversationList.scrollTop = top;
captureConversationScrollPosition();
return traceScrollMetrics();
}
function traceDisplayRows(trace, events) {
const rows = [];
let noisyRun = [];
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"m3-readonly": "node scripts/m3-readonly-contract.mjs",
"check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs scripts/sidebar-resize.test.mjs && node scripts/check.mjs",
"check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs scripts/sidebar-resize.test.mjs scripts/trace-scroll.test.mjs && node scripts/check.mjs",
"build": "node scripts/build.mjs",
"layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
"layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json",
+15
View File
@@ -886,15 +886,24 @@ assert.match(app, /eventsCompacted:\s*snapshot\.eventsCompacted === true/);
assert.match(app, /eventWindow:\s*snapshot\.eventWindow/);
assert.match(app, /eventCount:\s*Number\.isInteger\(trace\?\.eventCount\)/);
assert.match(app, /traceDetailsOpen:\s*new Map\(\)/);
assert.match(app, /conversationRenderVersion:\s*0/);
assert.match(app, /conversationScrollUserActiveUntil:\s*0/);
assert.match(app, /conversationScrollPosition:\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/);
assert.match(app, /traceScrollPositions:\s*new Map\(\)/);
assert.match(app, /traceScrollUserActiveUntil:\s*new Map\(\)/);
assert.match(app, /state\.traceDetailsOpen\.clear\(\)/);
assert.match(app, /state\.conversationRenderVersion \+= 1/);
assert.match(app, /state\.conversationScrollPosition\s*=\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/);
assert.match(app, /state\.traceScrollPositions\.clear\(\)/);
assert.match(app, /state\.traceScrollUserActiveUntil\.clear\(\)/);
assert.match(app, /function messageTraceUiKey/);
assert.match(app, /function defaultTraceDetailsOpen/);
assert.match(app, /initConversationScrollMemory\(\)/);
assert.match(app, /function initConversationScrollMemory/);
assert.match(app, /function markConversationScrollIntent/);
assert.match(app, /function markTraceScrollIntent/);
assert.match(app, /function scrollBottomGap/);
assert.match(app, /function scrollTopForPosition/);
assert.match(app, /function captureConversationScrollPosition/);
assert.match(app, /function restoreConversationScrollPosition/);
assert.match(app, /function captureTraceScrollPositions/);
@@ -905,10 +914,14 @@ assert.match(functionBody(app, "renderConversation"), /restoreConversationScroll
assert.match(functionBody(app, "renderConversation"), /captureTraceScrollPositions\(\)/);
assert.match(functionBody(app, "renderConversation"), /restoreTraceScrollPositions\(\)/);
assert.match(functionBody(app, "renderConversation"), /requestAnimationFrame/);
assert.match(functionBody(app, "renderConversation"), /renderVersion !== state\.conversationRenderVersion/);
assert.match(functionBody(app, "restoreTraceScrollPositions"), /isTraceScrollUserActive/);
assert.match(functionBody(app, "scrollTopForPosition"), /SCROLL_BOTTOM_PIN_PX/);
assert.match(functionBody(app, "messageTracePanel"), /state\.traceDetailsOpen\.get\(traceUiKey\)/);
assert.match(functionBody(app, "messageTracePanel"), /defaultTraceDetailsOpen\(message\)/);
assert.match(functionBody(app, "messageTracePanel"), /state\.traceDetailsOpen\.set\(traceUiKey,\s*details\.open\)/);
assert.match(functionBody(app, "messageTracePanel"), /list\.dataset\.traceUiKey\s*=\s*traceUiKey/);
assert.match(functionBody(app, "messageTracePanel"), /markTraceScrollIntent\(traceUiKey\)/);
assert.match(functionBody(app, "messageTracePanel"), /rememberTraceScrollPosition\(traceUiKey,\s*list\)/);
assert.match(functionBody(app, "messageTracePanel"), /list\.dataset\.traceMode\s*=\s*"all"/);
assert.match(functionBody(app, "messageTracePanel"), /renderTraceEventList\(list,\s*rows\)/);
@@ -916,6 +929,8 @@ assert.doesNotMatch(app, /CODE_AGENT_TRACE_PREVIEW_LIMIT|tracePreviewRows|收起
assert.match(app, /复制 JSON/);
assert.match(app, /下载 trace/);
assert.match(app, /function renderTraceEventList/);
assert.match(app, /function installWorkbenchTestHooks/);
assert.match(app, /hwlab-test-hooks/);
assert.match(app, /function traceDisplayRows/);
assert.match(app, /function traceDisplayRow/);
assert.match(app, /function traceToolOutputSummaryRow/);
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultCredentials = Object.freeze({
username: "admin",
password: "hwlab2026"
});
test("trace scroll stays user controlled while trace updates append", async () => {
const server = await startStaticServer(webRoot);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1366, height: 768 },
deviceScaleFactor: 1
});
await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 });
await login(page);
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 });
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.seedTraceMessage({ count: 90 }));
await page.waitForSelector(".message-trace-events[data-trace-ui-key]", { timeout: 12000 });
const initial = await traceMetrics(page);
assert.ok(initial.traceScrollHeight > initial.traceClientHeight + 200, JSON.stringify(initial));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(1_000_000, { user: false }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(8));
await page.waitForTimeout(80);
const pinned = await traceMetrics(page);
assert.ok(pinned.traceBottomGap <= 2, `trace should stay pinned at bottom when already at bottom: ${JSON.stringify(pinned)}`);
const userMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(180, { user: true }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
await page.waitForTimeout(120);
const afterAppend = await traceMetrics(page);
assert.ok(Math.abs(afterAppend.traceTop - userMiddle.traceTop) <= 8, `trace update fought the user's scroll position: before=${JSON.stringify(userMiddle)} after=${JSON.stringify(afterAppend)}`);
const userMoved = await page.evaluate(() => {
const before = window.__hwlabWorkbenchTestHooks.traceScrollMetrics();
return window.__hwlabWorkbenchTestHooks.setTraceScrollTop(before.traceTop + 180, { user: true });
});
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
await page.waitForTimeout(120);
const afterUserMove = await traceMetrics(page);
assert.ok(afterUserMove.traceTop >= userMoved.traceTop - 8, `trace update should not snap back after user scrolls down: before=${JSON.stringify(userMoved)} after=${JSON.stringify(afterUserMove)}`);
const conversationMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setConversationScrollTop(40, { user: true }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(4));
await page.waitForTimeout(120);
const afterConversationAppend = await traceMetrics(page);
assert.ok(Math.abs(afterConversationAppend.conversationTop - conversationMiddle.conversationTop) <= 8, `conversation scroll should not snap on trace update: before=${JSON.stringify(conversationMiddle)} after=${JSON.stringify(afterConversationAppend)}`);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
async function traceMetrics(page) {
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics());
}
async function login(page) {
await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 });
const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false);
if (!loginVisible) {
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
return;
}
await page.locator("#login-username").fill(defaultCredentials.username);
await page.locator("#login-password").fill(defaultCredentials.password);
await page.locator("#login-submit").click();
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
}
async function startStaticServer(rootDir) {
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
const resolved = path.resolve(rootDir, `.${pathname}`);
if (!resolved.startsWith(rootDir)) {
response.writeHead(403).end("forbidden");
return;
}
const body = await readFile(resolved);
response.writeHead(200, { "content-type": contentType(resolved) });
response.end(body);
} catch {
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("not found");
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
url: `http://127.0.0.1:${address.port}/`,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
};
}
function contentType(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
return "application/octet-stream";
}