From 2759e823e8d87f6dee2b2a93da657481953e40c0 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 25 May 2026 15:47:43 +0800 Subject: [PATCH] fix: preserve trace scroll user control --- web/hwlab-cloud-web/app.mjs | 163 +++++++++++++++++- web/hwlab-cloud-web/package.json | 2 +- web/hwlab-cloud-web/scripts/check.mjs | 15 ++ .../scripts/trace-scroll.test.mjs | 119 +++++++++++++ 4 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/trace-scroll.test.mjs diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index de931926..4b4b2905 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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 = []; diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 3c6c7d7d..a5a6105d 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -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", diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 2dc85fe6..dd203112 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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/); diff --git a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs new file mode 100644 index 00000000..69b0af60 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs @@ -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"; +}