fix: decouple trace updates from scroll

This commit is contained in:
Codex
2026-05-27 12:02:00 +08:00
parent 1eccede763
commit a163789586
2 changed files with 148 additions and 148 deletions
+129 -27
View File
@@ -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();
+19 -121
View File
@@ -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 <redacted>",
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) {