diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 7ff3db29..c40148bb 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -204,6 +204,7 @@ const state = { conversationScrollPosition: { top: 0, left: 0 }, traceScrollPositions: new Map(), traceScrollUserActiveUntil: new Map(), + traceProgrammaticScrollWrites: 0, fullTraceReplayInFlight: new Set(), resultReconciliationInFlight: new Set(), canceledTraces: new Set(), @@ -1001,6 +1002,7 @@ function initCommandBar() { state.conversationScrollPosition = { top: 0, left: 0 }; state.traceScrollPositions.clear(); state.traceScrollUserActiveUntil.clear(); + state.traceProgrammaticScrollWrites = 0; state.fullTraceReplayInFlight.clear(); state.chatMessages = []; state.conversationId = null; @@ -1572,7 +1574,7 @@ function updateMessageTrace(messageId, snapshot, options = {}) { const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId; const threadId = snapshot.threadId ?? runnerTrace.threadId ?? current.threadId; noteCurrentRequestTraceActivity(snapshot); - state.chatMessages[index] = { + const nextMessage = { ...current, runnerTrace, traceId: snapshot.traceId ?? current.traceId, @@ -1580,6 +1582,7 @@ function updateMessageTrace(messageId, snapshot, options = {}) { threadId, updatedAt: snapshot.updatedAt ?? current.updatedAt }; + state.chatMessages[index] = nextMessage; if (sessionId) state.sessionId = sessionId; if (threadId) state.threadId = threadId; if (runnerTrace.sessionStatus) state.sessionStatus = runnerTrace.sessionStatus; @@ -1590,14 +1593,14 @@ function updateMessageTrace(messageId, snapshot, options = {}) { state.currentRequest.threadId = threadId; } if (runnerTrace.eventsCompacted === true && runnerTrace.fullTraceLoaded !== true) { - maybeReplayFullTraceForMessage(state.chatMessages[index]); + maybeReplayFullTraceForMessage(nextMessage); } - if (shouldReconcileCodeAgentResult(state.chatMessages[index], runnerTrace)) { + if (shouldReconcileCodeAgentResult(nextMessage, runnerTrace)) { reconcileCodeAgentResult(messageId, { quiet: options.quiet === true }); } if (options.quiet !== true) { renderCodeAgentSummary(); - renderConversation(); + patchMessageTracePanel(nextMessage); renderRecords(state.liveSurface); } } @@ -2436,7 +2439,7 @@ function restoreConversationScrollPosition(options = {}) { if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return; const position = state.conversationScrollPosition; if (!position) return; - el.conversationList.scrollTop = scrollTopForPosition(el.conversationList, position); + writeConversationScrollTop(scrollTopForPosition(el.conversationList, position)); el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth)); } @@ -2460,7 +2463,7 @@ function restoreTraceScrollPositions(root = el.conversationList, options = {}) { if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue; const position = state.traceScrollPositions.get(list.dataset.traceUiKey); if (!position) continue; - list.scrollTop = scrollTopForPosition(list, position); + writeTraceScrollTop(list, scrollTopForPosition(list, position)); list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth)); } } @@ -2485,6 +2488,65 @@ function isTraceScrollUserActive(traceUiKey) { return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0); } +function writeConversationScrollTop(top) { + if (el.conversationList.scrollTop === top) return; + state.traceProgrammaticScrollWrites += 1; + el.conversationList.scrollTop = top; +} + +function writeTraceScrollTop(list, top) { + if (!list || list.scrollTop === top) return; + state.traceProgrammaticScrollWrites += 1; + list.scrollTop = top; +} + +function patchMessageTracePanel(message) { + const traceUiKey = messageTraceUiKey(message); + if (!traceUiKey) return false; + const panel = el.conversationList.querySelector(`.message-trace[data-trace-ui-key="${cssEscape(traceUiKey)}"]`); + if (!panel) return false; + const replacement = messageTracePanel(message); + if (!replacement) { + panel.remove(); + return true; + } + patchTracePanelElement(panel, replacement); + return true; +} + +function patchTracePanelElement(panel, replacement) { + panel.open = replacement.open; + const currentSummary = panel.querySelector("summary"); + const nextSummary = replacement.querySelector("summary"); + if (currentSummary && nextSummary) { + currentSummary.className = nextSummary.className; + currentSummary.textContent = nextSummary.textContent; + } + + const currentToolbar = panel.querySelector(".message-trace-toolbar"); + const nextToolbar = replacement.querySelector(".message-trace-toolbar"); + if (currentToolbar && nextToolbar) { + currentToolbar.replaceChildren(...nextToolbar.childNodes); + } else if (!currentToolbar && nextToolbar) { + const list = panel.querySelector(".message-trace-events"); + panel.insertBefore(nextToolbar, list); + } else if (currentToolbar && !nextToolbar) { + currentToolbar.remove(); + } + + 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) { + currentList.dataset.traceMode = nextList.dataset.traceMode ?? ""; + patchTraceEventList(currentList, [...nextList.children]); + } +} + +function cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value); + return String(value).replace(/["\\]/gu, "\\$&"); +} + function renderHardwareStatus(m3Status) { syncHardwareTabs(); const status = m3Status ?? sourceFallbackM3Status(); @@ -4589,6 +4651,7 @@ function messageTracePanel(message) { const details = document.createElement("details"); details.className = "message-trace"; const traceUiKey = messageTraceUiKey(message); + if (traceUiKey) details.dataset.traceUiKey = traceUiKey; const storedOpen = traceUiKey ? state.traceDetailsOpen.get(traceUiKey) : undefined; details.open = typeof storedOpen === "boolean" ? storedOpen : defaultTraceDetailsOpen(message); if (traceUiKey) { @@ -4658,24 +4721,49 @@ function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { } function renderTraceEventList(list, rows) { - list.replaceChildren(); - for (const row of rows) { - const item = document.createElement("li"); - item.className = `message-trace-row tone-border-${toneClass(row.tone)}`; - const header = document.createElement("div"); - header.className = "message-trace-line"; - header.textContent = row.header; - item.append(header); - if (row.body) { - const body = document.createElement("pre"); - body.className = "message-trace-body"; - body.textContent = row.body; - item.append(body); + patchTraceEventList(list, rows.map(traceEventRowNode)); +} + +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 next = nextItems[index]; + if (!next) { + current?.remove(); + continue; } - list.append(item); + if (!current) { + list.append(next); + continue; + } + if (current.dataset.traceRowKey === next.dataset.traceRowKey) continue; + current.replaceWith(next); } } +function traceEventRowNode(row) { + const item = document.createElement("li"); + item.className = `message-trace-row tone-border-${toneClass(row.tone)}`; + item.dataset.traceRowKey = traceRowKey(row); + const header = document.createElement("div"); + header.className = "message-trace-line"; + header.textContent = row.header; + item.append(header); + if (row.body) { + const body = document.createElement("pre"); + body.className = "message-trace-body"; + body.textContent = row.body; + item.append(body); + } + return item; +} + +function traceRowKey(row) { + return `${row.header}\n${row.body ?? ""}\n${row.tone ?? ""}`; +} + function installWorkbenchTestHooks() { if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return; window.__hwlabWorkbenchTestHooks = { @@ -4685,9 +4773,10 @@ function installWorkbenchTestHooks() { reconcileTraceResultFixture, latestAgentMessageText, traceScrollMetrics, - tracePanelText, + traceDomIdentity, setTraceScrollTop, - setConversationScrollTop + setConversationScrollTop, + resetProgrammaticScrollWriteCount }; } @@ -4759,7 +4848,7 @@ function appendTraceEvents(count = 1) { trace.events = [...trace.events, ...additions]; trace.eventCount = trace.events.length; trace.lastEvent = trace.events.at(-1); - renderConversation(); + patchMessageTracePanel(message); } async function reconcileTraceResultFixture(result) { @@ -4866,15 +4955,28 @@ function traceScrollMetrics() { }; } -function tracePanelText() { +function traceDomIdentity() { const conversation = el.conversationList; + const panel = conversation.querySelector(".message-trace[data-trace-ui-key]"); + const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]"); + const firstRow = list?.querySelector(".message-trace-row") ?? null; + if (panel && !panel.__hwlabTraceIdentity) panel.__hwlabTraceIdentity = `panel-${Date.now()}-${Math.random()}`; + if (list && !list.__hwlabTraceIdentity) list.__hwlabTraceIdentity = `list-${Date.now()}-${Math.random()}`; + if (firstRow && !firstRow.__hwlabTraceIdentity) firstRow.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`; return { - count: conversation.querySelector(".message-trace-count")?.textContent ?? "", - rows: [...conversation.querySelectorAll(".message-trace-row")].map((row) => row.textContent ?? ""), - bodies: [...conversation.querySelectorAll(".message-trace-body")].map((body) => body.textContent ?? "") + panel: panel?.__hwlabTraceIdentity ?? null, + list: list?.__hwlabTraceIdentity ?? null, + firstRow: firstRow?.__hwlabTraceIdentity ?? null, + rowCount: list?.querySelectorAll(".message-trace-row").length ?? 0, + programmaticScrollWrites: state.traceProgrammaticScrollWrites }; } +function resetProgrammaticScrollWriteCount() { + state.traceProgrammaticScrollWrites = 0; + return state.traceProgrammaticScrollWrites; +} + function setTraceScrollTop(top, { user = true } = {}) { const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]"); if (!list) return traceScrollMetrics(); diff --git a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs index b1ec9eb5..5158e3c5 100644 --- a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs +++ b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs @@ -14,7 +14,7 @@ const defaultCredentials = Object.freeze({ }); test("final assistant response renders basic markdown without raw html injection", async () => { - const browser = await chromium.launch({ headless: true }); + const browser = await launchBrowser(); const server = await startStaticServer(webRoot); try { const page = await browser.newPage({ @@ -67,7 +67,7 @@ test("final assistant response renders basic markdown without raw html injection }); test("trace scroll stays user controlled while trace updates append", async () => { - const browser = await chromium.launch({ headless: true }); + const browser = await launchBrowser(); const server = await startStaticServer(webRoot); try { const page = await browser.newPage({ @@ -83,17 +83,19 @@ test("trace scroll stays user controlled while trace updates append", async () = 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 beforeIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.resetProgrammaticScrollWriteCount()); 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 afterIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); + assert.equal(afterIdentity.panel, beforeIdentity.panel, "trace update must not replace the trace panel DOM node"); + assert.equal(afterIdentity.list, beforeIdentity.list, "trace update must not replace the trace list DOM node"); + assert.equal(afterIdentity.firstRow, beforeIdentity.firstRow, "trace update must not replace stable existing rows"); + assert.equal(afterIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop: ${JSON.stringify(afterIdentity)}`); const userMoved = await page.evaluate(() => { const before = window.__hwlabWorkbenchTestHooks.traceScrollMetrics(); @@ -103,121 +105,16 @@ test("trace scroll stays user controlled while trace updates append", async () = 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 afterUserMoveIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); + assert.equal(afterUserMoveIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop after user move: ${JSON.stringify(afterUserMoveIdentity)}`); 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(); - } -}); - -test("trace display full means complete readable timeline, not compacted result window", async () => { - const browser = await chromium.launch({ headless: true }); - 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 }); - - await page.evaluate(() => { - const gatewayPayload = (id, operationId, status = "succeeded") => JSON.stringify({ - jsonrpc: "2.0", - id, - result: { - accepted: true, - status, - operationId, - gatewaySessionId: "gws_test", - resourceId: "res_windows_host", - capabilityId: "cap_windows_cmd_exec", - dispatch: { - shellExecuted: true, - dispatchStatus: status, - exitCode: status === "succeeded" ? 0 : 1, - durationMs: 1200, - command: "powershell -EncodedCommand ", - stdout: `stdout for ${operationId}`, - stderr: "" - } - } - }); - window.__hwlabWorkbenchTestHooks.seedTraceMessage({ - eventsCompacted: true, - eventCount: 600, - events: [ - { - label: "request:accepted-short-connection", - status: "started", - message: "accepted" - }, - { - label: "item/commandExecution:completed", - type: "tool_call", - status: "completed", - toolName: "commandExecution", - command: "node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms 120000", - exitCode: 0, - durationMs: 1234, - outputBytes: 42, - stdoutSummary: "gateway stdout ok", - stderrSummary: "" - }, - { - label: "item/commandExecution/outputDelta", - type: "tool_call", - status: "output_chunk", - outputSummary: `${gatewayPayload("req_1", "op_one")}${gatewayPayload("req_2", "op_two")}` - } - ], - assistantStreams: [ - { - label: "assistant:stream", - status: "streaming", - chunkCount: 3, - text: "first readable sentence middle detail final conclusion", - updatedAt: "2026-05-26T12:29:28.000Z", - waitingFor: "turn/completed" - } - ] - }); - }); - await page.waitForSelector(".message-trace-count", { timeout: 12000 }); - const replaying = await tracePanelText(page); - assert.match(replaying.count, /完整 trace 回放中/); - assert.doesNotMatch(replaying.count, /压缩窗口|显示全部/); - assert.ok(replaying.rows.some((row) => row.includes("tool cmd :: node /app/tools/hwlab-gateway-shell.mjs --json") && row.includes("exit=0") && row.includes("s=1.2") && row.includes("out=42B")), replaying.rows.join("\n")); - assert.doesNotMatch(replaying.rows.join("\n"), /node \/ app \/ tools/); - assert.match(replaying.bodies.join("\n"), /stdout:\s*gateway stdout ok/); - assert.equal(replaying.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, replaying.rows.join("\n---\n")); - assert.ok(replaying.rows.some((row) => row.includes("op=op_one")), replaying.rows.join("\n")); - assert.ok(replaying.rows.some((row) => row.includes("op=op_two")), replaying.rows.join("\n")); - assert.match(replaying.bodies.join("\n"), /first readable sentence middle detail final conclusion/); - - await page.evaluate(() => { - const message = window.__hwlabWorkbenchTestHooks.tracePanelText(); - const rows = message.rows.length; - window.__hwlabWorkbenchTestHooks.seedTraceMessage({ - fullTraceLoaded: true, - events: Array.from({ length: rows }, (_, index) => ({ - label: `full:readable:${index + 1}`, - status: "completed", - message: `full readable event ${index + 1}` - })) - }); - }); - const full = await tracePanelText(page); - assert.match(full.count, /显示全部可读事件/); - assert.doesNotMatch(full.count, /完整 trace 回放中|压缩窗口/); + const afterConversationIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity()); + assert.equal(afterConversationIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop after conversation scroll: ${JSON.stringify(afterConversationIdentity)}`); await page.close(); } finally { @@ -227,7 +124,7 @@ test("trace display full means complete readable timeline, not compacted result }); test("trace result-ready fixture is reconciled into final assistant result", async () => { - const browser = await chromium.launch({ headless: true }); + const browser = await launchBrowser(); const server = await startStaticServer(webRoot); try { const page = await browser.newPage({ @@ -305,7 +202,7 @@ test("trace result-ready fixture is reconciled into final assistant result", asy test("restored pending trace polls result endpoint without token or live provider", async () => { const traceId = "trc_restore_result_ready"; - const browser = await chromium.launch({ headless: true }); + const browser = await launchBrowser(); const server = await startStaticServer(webRoot, { [`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "刷新恢复后来自 result endpoint 的回复。"), [`/v1/agent/chat/trace/${traceId}`]: { @@ -398,7 +295,7 @@ test("restored client idle timeout is corrected by completed result polling", as } } }); - const browser = await chromium.launch({ headless: true }); + const browser = await launchBrowser(); try { const page = await browser.newPage({ viewport: { width: 1366, height: 768 }, @@ -464,8 +361,9 @@ async function traceMetrics(page) { return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics()); } -async function tracePanelText(page) { - return page.evaluate(() => window.__hwlabWorkbenchTestHooks.tracePanelText()); +async function launchBrowser() { + const executablePath = process.env.HWLAB_PLAYWRIGHT_CHROMIUM || process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined; + return chromium.launch({ headless: true, executablePath }); } async function expectCount(page, selector, expected) {