From 35b1445b765c656ac021cafba6d2b6667b106836 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 14:50:19 +0800 Subject: [PATCH] fix: preserve inner trace scroll --- web/hwlab-cloud-web/app.mjs | 210 ++++++++++++++++-- .../scripts/trace-scroll.test.mjs | 74 +++++- 2 files changed, 265 insertions(+), 19 deletions(-) diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index c40148bb..7af134e4 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -204,6 +204,8 @@ const state = { conversationScrollPosition: { top: 0, left: 0 }, traceScrollPositions: new Map(), traceScrollUserActiveUntil: new Map(), + traceBodyScrollPositions: new Map(), + traceBodyScrollUserActiveUntil: new Map(), traceProgrammaticScrollWrites: 0, fullTraceReplayInFlight: new Set(), resultReconciliationInFlight: new Set(), @@ -2387,6 +2389,7 @@ function renderConversation() { const renderVersion = ++state.conversationRenderVersion; captureConversationScrollPosition(); captureTraceScrollPositions(); + captureTraceBodyScrollPositions(); const introMessages = [ { role: "system", @@ -2399,11 +2402,13 @@ function renderConversation() { replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); restoreConversationScrollPosition(); restoreTraceScrollPositions(); + restoreTraceBodyScrollPositions(); scheduleCodeAgentSessionPersist(); window.requestAnimationFrame(() => { if (renderVersion !== state.conversationRenderVersion) return; restoreConversationScrollPosition({ deferred: true }); restoreTraceScrollPositions(el.conversationList, { deferred: true }); + restoreTraceBodyScrollPositions(el.conversationList, { deferred: true }); }); } @@ -2468,6 +2473,62 @@ function restoreTraceScrollPositions(root = el.conversationList, options = {}) { } } +function captureTraceBodyScrollPositions(root = el.conversationList) { + for (const body of root.querySelectorAll(".message-trace-body")) { + rememberTraceBodyScrollPosition(body); + } +} + +function traceBodyScrollKey(body) { + const list = body?.closest?.(".message-trace-events[data-trace-ui-key]"); + const row = body?.closest?.(".message-trace-row[data-trace-row-id]"); + const traceUiKey = list?.dataset?.traceUiKey; + const rowId = row?.dataset?.traceRowId; + return traceUiKey && rowId ? `${traceUiKey}:${rowId}` : null; +} + +function rememberTraceBodyScrollPosition(body) { + const key = traceBodyScrollKey(body); + if (!key || !body) return; + state.traceBodyScrollPositions.set(key, elementScrollPosition(body)); +} + +function restoreTraceBodyScrollPositions(root = el.conversationList, options = {}) { + for (const body of root.querySelectorAll(".message-trace-body")) { + const key = traceBodyScrollKey(body); + if (!key) continue; + if (options.deferred === true && isTraceBodyScrollUserActive(key)) continue; + const position = state.traceBodyScrollPositions.get(key); + if (!position) continue; + restoreElementScrollPosition(body, position); + } +} + +function markTraceBodyScrollIntent(body) { + const key = traceBodyScrollKey(body); + if (!key) return; + state.traceBodyScrollUserActiveUntil.set(key, Date.now() + SCROLL_USER_ACTIVITY_MS); +} + +function isTraceBodyScrollUserActive(key) { + if (!key) return false; + return Date.now() < Number(state.traceBodyScrollUserActiveUntil.get(key) ?? 0); +} + +function elementScrollPosition(element) { + return { + top: element.scrollTop, + left: element.scrollLeft, + bottomGap: scrollBottomGap(element) + }; +} + +function restoreElementScrollPosition(element, position) { + if (!element || !position) return; + element.scrollTop = scrollTopForPosition(element, position); + element.scrollLeft = Math.min(position.left, Math.max(0, element.scrollWidth - element.clientWidth)); +} + function scrollBottomGap(element) { return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop); } @@ -2537,8 +2598,10 @@ function patchTracePanelElement(panel, replacement) { const currentList = panel.querySelector(".message-trace-events[data-trace-ui-key]"); const nextList = replacement.querySelector(".message-trace-events[data-trace-ui-key]"); if (currentList && nextList) { + captureTraceBodyScrollPositions(currentList); currentList.dataset.traceMode = nextList.dataset.traceMode ?? ""; patchTraceEventList(currentList, [...nextList.children]); + restoreTraceBodyScrollPositions(currentList); } } @@ -4721,31 +4784,72 @@ function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { } function renderTraceEventList(list, rows) { - patchTraceEventList(list, rows.map(traceEventRowNode)); + patchTraceEventList(list, rows.map((row, index) => traceEventRowNode(row, index))); } function patchTraceEventList(list, nextItems) { - const currentItems = [...list.children]; - const max = Math.max(currentItems.length, nextItems.length); - for (let index = 0; index < max; index += 1) { - const current = currentItems[index]; + const currentById = new Map(); + for (const current of [...list.children]) { + const rowId = current.dataset.traceRowId; + if (rowId && !currentById.has(rowId)) currentById.set(rowId, current); + } + const retained = new Set(); + for (let index = 0; index < nextItems.length; index += 1) { const next = nextItems[index]; - if (!next) { - current?.remove(); - continue; - } - if (!current) { - list.append(next); - continue; - } - if (current.dataset.traceRowKey === next.dataset.traceRowKey) continue; - current.replaceWith(next); + const rowId = next.dataset.traceRowId; + const current = rowId ? currentById.get(rowId) : null; + const item = current && !retained.has(current) ? current : next; + if (item === current) patchTraceRowElement(current, next); + const before = list.children[index] ?? null; + if (before !== item) list.insertBefore(item, before); + retained.add(item); + } + for (const current of [...list.children]) { + if (!retained.has(current)) current.remove(); } } -function traceEventRowNode(row) { +function patchTraceRowElement(current, next) { + current.className = next.className; + current.dataset.traceRowKey = next.dataset.traceRowKey ?? ""; + current.dataset.traceRowId = next.dataset.traceRowId ?? ""; + const currentHeader = current.querySelector(".message-trace-line"); + const nextHeader = next.querySelector(".message-trace-line"); + if (currentHeader && nextHeader) { + currentHeader.className = nextHeader.className; + if (currentHeader.textContent !== nextHeader.textContent) currentHeader.textContent = nextHeader.textContent; + } else if (!currentHeader && nextHeader) { + current.prepend(nextHeader); + } else if (currentHeader && !nextHeader) { + currentHeader.remove(); + } + const currentBody = current.querySelector(".message-trace-body"); + const nextBody = next.querySelector(".message-trace-body"); + if (currentBody && nextBody) { + patchTraceBodyElement(currentBody, nextBody); + } else if (!currentBody && nextBody) { + current.append(nextBody); + } else if (currentBody && !nextBody) { + currentBody.remove(); + } +} + +function patchTraceBodyElement(currentBody, nextBody) { + const position = elementScrollPosition(currentBody); + currentBody.className = nextBody.className; + currentBody.dataset.traceBodyId = nextBody.dataset.traceBodyId ?? ""; + if (currentBody.textContent !== nextBody.textContent) { + currentBody.textContent = nextBody.textContent; + } + restoreElementScrollPosition(currentBody, position); + rememberTraceBodyScrollPosition(currentBody); +} + +function traceEventRowNode(row, index = 0) { const item = document.createElement("li"); item.className = `message-trace-row tone-border-${toneClass(row.tone)}`; + const rowId = traceRowId(row, index); + item.dataset.traceRowId = rowId; item.dataset.traceRowKey = traceRowKey(row); const header = document.createElement("div"); header.className = "message-trace-line"; @@ -4754,12 +4858,30 @@ function traceEventRowNode(row) { if (row.body) { const body = document.createElement("pre"); body.className = "message-trace-body"; + body.dataset.traceBodyId = rowId; + installTraceBodyScrollListeners(body); body.textContent = row.body; item.append(body); } return item; } +function installTraceBodyScrollListeners(body) { + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + body.addEventListener(eventName, () => markTraceBodyScrollIntent(body), { passive: true }); + } + body.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markTraceBodyScrollIntent(body); + }); + body.addEventListener("scroll", () => rememberTraceBodyScrollPosition(body), { passive: true }); +} + +function traceRowId(row, index = 0) { + if (row?.rowId) return String(row.rowId); + if (row?.seq !== null && row?.seq !== undefined) return `event:${row.seq}`; + return `row:${index}:${traceRowKey(row)}`; +} + function traceRowKey(row) { return `${row.header}\n${row.body ?? ""}\n${row.tone ?? ""}`; } @@ -4774,6 +4896,9 @@ function installWorkbenchTestHooks() { latestAgentMessageText, traceScrollMetrics, traceDomIdentity, + traceBodyDomIdentity, + setTraceBodyScrollTop, + replaceTraceAssistantStream, setTraceScrollTop, setConversationScrollTop, resetProgrammaticScrollWriteCount @@ -4851,6 +4976,22 @@ function appendTraceEvents(count = 1) { patchMessageTracePanel(message); } +function replaceTraceAssistantStream(stream = {}) { + const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); + if (!message) return null; + const trace = message.runnerTrace; + trace.assistantStreams = [{ + itemId: "assistant-message", + chunkCount: Math.max(1, Number(stream.chunkCount ?? 1)), + createdAt: stream.createdAt ?? new Date(1779699000000).toISOString(), + updatedAt: stream.updatedAt ?? new Date(1779699001000 + Math.max(1, Number(stream.chunkCount ?? 1)) * 1000).toISOString(), + waitingFor: stream.waitingFor ?? "turn/completed", + text: String(stream.text ?? "assistant stream fixture") + }]; + patchMessageTracePanel(message); + return traceBodyDomIdentity(".message-trace-events[data-trace-ui-key] li:last-child pre"); +} + async function reconcileTraceResultFixture(result) { const message = state.chatMessages.find((item) => item.runnerTrace?.traceId || item.traceId); if (!message) return null; @@ -4972,6 +5113,24 @@ function traceDomIdentity() { }; } +function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child pre") { + const body = el.conversationList.querySelector(selector); + const row = body?.closest(".message-trace-row") ?? null; + if (body && !body.__hwlabTraceIdentity) body.__hwlabTraceIdentity = `body-${Date.now()}-${Math.random()}`; + if (row && !row.__hwlabTraceIdentity) row.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`; + return { + selector, + body: body?.__hwlabTraceIdentity ?? null, + row: row?.__hwlabTraceIdentity ?? null, + rowId: row?.dataset.traceRowId ?? null, + top: body?.scrollTop ?? null, + left: body?.scrollLeft ?? null, + scrollHeight: body?.scrollHeight ?? null, + clientHeight: body?.clientHeight ?? null, + text: body?.textContent ?? null + }; +} + function resetProgrammaticScrollWriteCount() { state.traceProgrammaticScrollWrites = 0; return state.traceProgrammaticScrollWrites; @@ -4986,6 +5145,15 @@ function setTraceScrollTop(top, { user = true } = {}) { return traceScrollMetrics(); } +function setTraceBodyScrollTop(selector, top, { user = true } = {}) { + const body = el.conversationList.querySelector(selector); + if (!body) return traceBodyDomIdentity(selector); + if (user) markTraceBodyScrollIntent(body); + body.scrollTop = top; + rememberTraceBodyScrollPosition(body); + return traceBodyDomIdentity(selector); +} + function setConversationScrollTop(top, { user = true } = {}) { if (user) markConversationScrollIntent(); el.conversationList.scrollTop = top; @@ -5035,15 +5203,16 @@ function traceDisplayRows(trace, events) { function traceAssistantStreamRows(trace) { const streams = Array.isArray(trace?.assistantStreams) ? trace.assistantStreams.filter(Boolean) : []; - return streams.map((stream) => traceAssistantStreamRow(trace, stream)).filter(Boolean); + return streams.map((stream, index) => traceAssistantStreamRow(trace, stream, index)).filter(Boolean); } -function traceAssistantStreamRow(trace, stream) { +function traceAssistantStreamRow(trace, stream, index = 0) { const chunkCount = Math.max(0, Number(stream?.chunkCount ?? 0)); if (chunkCount <= 0) return null; const clock = traceClock(stream.updatedAt ?? stream.createdAt); const total = formatTraceDuration(traceRelativeMs(trace, { createdAt: stream.updatedAt ?? stream.createdAt })); return { + rowId: `assistant-stream:${stream.itemId ?? stream.messageId ?? index}`, seq: null, tone: "source", header: `${clock} total=${total} stream assistant message x${chunkCount}`, @@ -5089,6 +5258,7 @@ function traceToolOutputSummaryRows(trace, events) { suffix.trim() || null ].filter(Boolean).join(" "); return { + rowId: `gateway-jsonrpc:${event.seq ?? first.seq ?? "unknown"}:${payload.id ?? index}`, seq: event.seq ?? null, tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked", header, @@ -5099,6 +5269,7 @@ function traceToolOutputSummaryRows(trace, events) { const clock = traceClock(first.createdAt); const total = formatTraceDuration(traceRelativeMs(trace, first)); return [{ + rowId: `tool-output:${first.seq ?? "unknown"}`, seq: first.seq ?? null, tone: "source", header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`, @@ -5121,6 +5292,7 @@ function traceNoiseSummaryRow(trace, events) { ? `assistant message x${visibleEvents.length}` : `trace noise x${visibleEvents.length}`; return { + rowId: `trace-noise:${visibleEvents[0]?.seq ?? last.seq ?? "unknown"}:${assistantChunks.length > 0 ? "assistant" : "events"}`, seq: last.seq ?? null, tone: "source", header: `${clock} total=${total} stream ${label}`, @@ -5174,6 +5346,7 @@ function traceDisplayRow(trace, event, options = {}) { ].filter(Boolean).join(" "); const body = traceDisplayBody(event); return { + rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, seq: event.seq ?? null, tone: traceEventTone(event), header: `${clock} total=${total}${meta ? ` ${meta}` : ""}${label ? ` ${label}` : ""}`, @@ -5196,6 +5369,7 @@ function traceCommandExecutionRow(trace, event) { event.itemId ? `item=${event.itemId}` : null ].filter(Boolean).join(" "); return { + rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "command"}:${event.createdAt ?? "unknown"}`}`, seq: event.seq ?? null, tone: traceEventTone(event), header: `${clock} total=${total} ${meta}`, diff --git a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs index 348cb94b..e5a30bb4 100644 --- a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs +++ b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; @@ -123,6 +124,70 @@ test("trace scroll stays user controlled while trace updates append", async () = } }); +test("inner trace message body scroll stays stable when trace rows move or stream text updates", async () => { + const browser = await launchBrowser(); + const server = await startStaticServer(webRoot); + 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 }); + + const longStreamText = (chunk) => Array.from({ length: 90 }, (_, index) => `assistant stream chunk ${chunk} line ${index + 1} ${"message ".repeat(8)}`).join("\n"); + const longEvents = Array.from({ length: 110 }, (_, index) => ({ + seq: index + 1, + label: "trace:inner-scroll-contract", + type: "trace", + status: "observed", + message: Array.from({ length: 70 }, (_, line) => `event ${index + 1} line ${line + 1} ${"payload ".repeat(8)}`).join("\n") + })); + + await page.evaluate(({ events, text }) => window.__hwlabWorkbenchTestHooks.seedTraceMessage({ + traceId: "trc_inner_scroll_contract", + messageId: "msg_inner_scroll_contract", + events, + assistantStreams: [{ itemId: "assistant-message", chunkCount: 1, waitingFor: "turn/completed", text }] + }), { events: longEvents, text: longStreamText(1) }); + await page.waitForSelector(".message-trace-events[data-trace-ui-key] li:nth-child(97) pre", { timeout: 12000 }); + + const eventBodySelector = ".message-trace-events[data-trace-ui-key] li:nth-child(97) pre"; + const streamBodySelector = ".message-trace-events[data-trace-ui-key] li:last-child pre"; + const eventBefore = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 90, { user: true }), eventBodySelector); + const streamBefore = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 90, { user: true }), streamBodySelector); + assert.ok(eventBefore.scrollHeight > eventBefore.clientHeight + 80, JSON.stringify(eventBefore)); + assert.ok(streamBefore.scrollHeight > streamBefore.clientHeight + 800, JSON.stringify(streamBefore)); + + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(5)); + await page.waitForTimeout(120); + const eventAfterAppend = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), eventBodySelector); + const streamAfterAppend = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector); + assert.equal(eventAfterAppend.body, eventBefore.body, "stable existing event body node must be reused while appending trace rows"); + assert.equal(eventAfterAppend.row, eventBefore.row, "stable existing event row node must be reused while appending trace rows"); + assert.ok(Math.abs(eventAfterAppend.top - eventBefore.top) <= 8, `event body scroll changed: before=${JSON.stringify(eventBefore)} after=${JSON.stringify(eventAfterAppend)}`); + assert.equal(streamAfterAppend.body, streamBefore.body, "assistant stream body node must move instead of being replaced when new events append before it"); + assert.equal(streamAfterAppend.row, streamBefore.row, "assistant stream row node must move instead of being replaced when new events append before it"); + assert.ok(Math.abs(streamAfterAppend.top - streamBefore.top) <= 8, `assistant stream body scroll changed after row move: before=${JSON.stringify(streamBefore)} after=${JSON.stringify(streamAfterAppend)}`); + + await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 120, { user: true }), streamBodySelector); + const streamBeforeTextUpdate = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector); + await page.evaluate((text) => window.__hwlabWorkbenchTestHooks.replaceTraceAssistantStream({ chunkCount: 2, text }), longStreamText(2)); + await page.waitForTimeout(120); + const streamAfterTextUpdate = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector); + assert.equal(streamAfterTextUpdate.body, streamBeforeTextUpdate.body, "assistant stream body node must survive stream text growth"); + assert.equal(streamAfterTextUpdate.row, streamBeforeTextUpdate.row, "assistant stream row node must survive stream text growth"); + assert.ok(Math.abs(streamAfterTextUpdate.top - streamBeforeTextUpdate.top) <= 8, `assistant stream body scroll changed after text update: before=${JSON.stringify(streamBeforeTextUpdate)} after=${JSON.stringify(streamAfterTextUpdate)}`); + assert.match(streamAfterTextUpdate.text, /chunk 2/u); + + await page.close(); + } finally { + await browser.close(); + await server.close(); + } +}); + test("trace result-ready fixture is reconciled into final assistant result", async () => { const browser = await launchBrowser(); const server = await startStaticServer(webRoot); @@ -362,10 +427,17 @@ async function traceMetrics(page) { } async function launchBrowser() { - const executablePath = process.env.HWLAB_PLAYWRIGHT_CHROMIUM || process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined; + const executablePath = process.env.HWLAB_PLAYWRIGHT_CHROMIUM || process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || systemChromiumExecutablePath(); return chromium.launch({ headless: true, executablePath }); } +function systemChromiumExecutablePath() { + for (const candidate of ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"]) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + async function expectCount(page, selector, expected) { assert.equal(await page.locator(selector).count(), expected, selector); }