fix(ui): keep native history anchor stable (#123103)

This commit is contained in:
Peter Steinberger
2026-08-13 02:58:51 -07:00
committed by GitHub
parent 600126acaa
commit 7a7f6dfbd4
3 changed files with 114 additions and 55 deletions
+11 -11
View File
@@ -2395,11 +2395,11 @@ importers:
specifier: workspace:*
version: link:../packages/workboard-contract
'@tanstack/lit-virtual':
specifier: 3.13.35
version: 3.13.35(lit@3.3.3)
specifier: 3.13.36
version: 3.13.36(lit@3.3.3)
'@tanstack/virtual-core':
specifier: 3.17.6
version: 3.17.6
specifier: 3.17.7
version: 3.17.7
dompurify:
specifier: 3.4.12
version: 3.4.12
@@ -5179,13 +5179,13 @@ packages:
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tanstack/lit-virtual@3.13.35':
resolution: {integrity: sha512-ZFUpMwYHsWelrBleD6JekvTwtHYBWzM79/5f3MrrdyDlMUBi0XfuqKB9wNJM0zVwyuu5uOUWQA+Gt3BLwzRHlQ==}
'@tanstack/lit-virtual@3.13.36':
resolution: {integrity: sha512-dju+ZvpztbcSZjWLllJIgHioXWypAm8hq9ALqFfAt/9hmRtU3gZKNEWgy+pgxtbHMouq2hQFrXWKfF3LRXKwJQ==}
peerDependencies:
lit: ^3.1.0
'@tanstack/virtual-core@3.17.6':
resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==}
'@tanstack/virtual-core@3.17.7':
resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==}
'@thi.ng/bitstream@2.4.54':
resolution: {integrity: sha512-uInkAJge5O0bWWEaYKrQpMccPbFg0z6eIA5NDCJXPm7l3rjlDje6RBHBXll3LiQz9Y051EdzlAEQRaB5hEifdg==}
@@ -12332,12 +12332,12 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tanstack/lit-virtual@3.13.35(lit@3.3.3)':
'@tanstack/lit-virtual@3.13.36(lit@3.3.3)':
dependencies:
'@tanstack/virtual-core': 3.17.6
'@tanstack/virtual-core': 3.17.7
lit: 3.3.3
'@tanstack/virtual-core@3.17.6': {}
'@tanstack/virtual-core@3.17.7': {}
'@thi.ng/bitstream@2.4.54':
dependencies:
+2 -2
View File
@@ -32,8 +32,8 @@
"@openclaw/session-url-contract": "workspace:*",
"@openclaw/uirouter": "0.1.1",
"@openclaw/workboard-contract": "workspace:*",
"@tanstack/lit-virtual": "3.13.35",
"@tanstack/virtual-core": "3.17.6",
"@tanstack/lit-virtual": "3.13.36",
"@tanstack/virtual-core": "3.17.7",
"dompurify": "3.4.12",
"ghostty-web": "0.4.0",
"highlight.js": "11.11.1",
+101 -42
View File
@@ -28,6 +28,13 @@ type VirtualRowPaintSample = {
type VirtualRowPaintProbe = {
frameIds: number[];
observer: MutationObserver;
pendingSamples: number;
samples: VirtualRowPaintSample[];
timerIds: number[];
};
type VirtualRowPaintResult = {
pending: boolean;
samples: VirtualRowPaintSample[];
};
@@ -71,12 +78,15 @@ async function startVirtualRowPaintProbe(thread: Locator, anchor: VisibleVirtual
if (staleProbe) {
staleProbe.observer.disconnect();
staleProbe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId));
staleProbe.timerIds.forEach((timerId) => clearTimeout(timerId));
delete target.chatPrependPaintProbe;
}
const probe: VirtualRowPaintProbe = {
frameIds: [],
observer: new MutationObserver(() => undefined),
pendingSamples: 0,
samples: [],
timerIds: [],
};
const sample = () => {
const viewport = element.getBoundingClientRect();
@@ -100,18 +110,30 @@ async function startVirtualRowPaintProbe(thread: Locator, anchor: VisibleVirtual
viewportTop: rect ? rect.top - viewport.top : null,
});
};
const scheduleSample = () => {
if (probe.frameIds.length > 0) {
return;
const removePendingId = (ids: number[], id: number) => {
const index = ids.indexOf(id);
if (index !== -1) {
ids.splice(index, 1);
}
};
const scheduleSample = () => {
// Each mutation batch owns a post-paint sample; later mutations must not
// cancel an earlier frame that could expose a visible anchor jump.
probe.pendingSamples += 1;
const firstFrame = requestAnimationFrame(() => {
removePendingId(probe.frameIds, firstFrame);
const secondFrame = requestAnimationFrame(() => {
probe.frameIds = [];
sample();
removePendingId(probe.frameIds, secondFrame);
const timerId = window.setTimeout(() => {
removePendingId(probe.timerIds, timerId);
sample();
probe.pendingSamples -= 1;
}, 0);
probe.timerIds.push(timerId);
});
probe.frameIds = [secondFrame];
probe.frameIds.push(secondFrame);
});
probe.frameIds = [firstFrame];
probe.frameIds.push(firstFrame);
};
probe.observer = new MutationObserver(scheduleSample);
probe.observer.observe(element, {
@@ -124,7 +146,24 @@ async function startVirtualRowPaintProbe(thread: Locator, anchor: VisibleVirtual
}, anchor);
}
async function stopVirtualRowPaintProbe(thread: Locator): Promise<VirtualRowPaintSample[]> {
async function readVirtualRowPaintProbe(thread: Locator) {
return thread.evaluate(() => {
const probe = (
globalThis as typeof globalThis & {
chatPrependPaintProbe?: VirtualRowPaintProbe;
}
).chatPrependPaintProbe;
if (!probe) {
throw new Error("expected an active virtual row paint probe");
}
return {
pendingSamples: probe.pendingSamples,
samples: probe.samples,
};
});
}
async function stopVirtualRowPaintProbe(thread: Locator): Promise<VirtualRowPaintResult> {
return thread.evaluate(() => {
const target = globalThis as typeof globalThis & {
chatPrependPaintProbe?: VirtualRowPaintProbe;
@@ -133,42 +172,52 @@ async function stopVirtualRowPaintProbe(thread: Locator): Promise<VirtualRowPain
if (!probe) {
throw new Error("expected an active virtual row paint probe");
}
const pending = probe.pendingSamples > 0;
probe.observer.disconnect();
probe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId));
probe.timerIds.forEach((timerId) => clearTimeout(timerId));
delete target.chatPrependPaintProbe;
return probe.samples;
return { pending, samples: probe.samples };
});
}
function expectPaintedVirtualRowAnchor(
anchor: VisibleVirtualRow,
samples: VirtualRowPaintSample[],
) {
const evidence = JSON.stringify({ anchor, samples });
expect(samples.length, evidence).toBeGreaterThan(0);
expect(
samples.some(
function virtualRowAnchorStatus(anchor: VisibleVirtualRow, samples: VirtualRowPaintSample[]) {
return {
advanced: samples.some(
(sample) =>
(sample.index !== null && sample.index > anchor.index) ||
sample.totalSize > anchor.totalSize,
),
evidence,
).toBe(true);
expect(
samples.every((sample) => sample.viewportTop !== null),
evidence,
).toBe(true);
expect(
samples.every((sample) => sample.intersectsViewport),
evidence,
).toBe(true);
expect(
samples.every(
anchored: samples.every(
(sample) =>
sample.viewportTop !== null && Math.abs(sample.viewportTop - anchor.viewportTop) <= 2,
),
present: samples.length > 0 && samples.every((sample) => sample.viewportTop !== null),
visible: samples.every((sample) => sample.intersectsViewport),
};
}
async function waitForPaintedVirtualRowAnchor(thread: Locator, anchor: VisibleVirtualRow) {
await expect
.poll(async () => {
const probe = await readVirtualRowPaintProbe(thread);
return probe.pendingSamples === 0 && virtualRowAnchorStatus(anchor, probe.samples).advanced;
})
.toBe(true);
}
function expectPaintedVirtualRowAnchor(anchor: VisibleVirtualRow, result: VirtualRowPaintResult) {
const evidence = JSON.stringify({ anchor, ...result });
expect(
{ pending: result.pending, ...virtualRowAnchorStatus(anchor, result.samples) },
evidence,
).toBe(true);
).toEqual({
pending: false,
advanced: true,
anchored: true,
present: true,
visible: true,
});
}
function resumableClaudeCatalog() {
@@ -704,7 +753,7 @@ suite.define(() => {
expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0);
const anchor = await captureTopVisibleVirtualRow(thread);
await startVirtualRowPaintProbe(thread, anchor);
let paintedSamples: VirtualRowPaintSample[];
let paintResult: VirtualRowPaintResult;
try {
await gateway.resolveDeferred("sessions.catalog.read");
await expect
@@ -715,12 +764,11 @@ suite.define(() => {
),
)
.toBe(41);
// Each mutation is sampled after a full paint, once anchor compensation has settled.
await page.clock.runFor(100);
} finally {
paintedSamples = await stopVirtualRowPaintProbe(thread);
paintResult = await stopVirtualRowPaintProbe(thread);
}
expectPaintedVirtualRowAnchor(anchor, paintedSamples);
expectPaintedVirtualRowAnchor(anchor, paintResult);
expect(
await catalogPane.locator(".agent-chat__composer-combobox > textarea").isDisabled(),
).toBe(true);
@@ -774,7 +822,7 @@ suite.define(() => {
it("auto-loads older native history with a spinner and stable viewport", async () => {
const page = await suite.browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.clock.install();
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const historyMessage = (seq: number, prefix: string) => ({
__openclaw: { seq },
content: [
@@ -838,9 +886,16 @@ suite.define(() => {
await page.locator('.chat-virtual-row:not([data-virtual-row-key="history"])').first().waitFor();
await gateway.waitForRequest("chat.history");
await page.locator(".chat-history-loading").waitFor();
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
await page.screenshot({
path: path.join(artifactDir, "01-native-history-loading.png"),
fullPage: true,
});
}
const anchor = await captureTopVisibleVirtualRow(thread);
await startVirtualRowPaintProbe(thread, anchor);
let paintedSamples: VirtualRowPaintSample[];
let paintResult: VirtualRowPaintResult;
try {
await gateway.resolveDeferred("chat.history");
await expect
@@ -854,12 +909,17 @@ suite.define(() => {
),
)
.toBe(140);
// Each mutation is sampled after a full paint, once anchor compensation has settled.
await page.clock.runFor(100);
await waitForPaintedVirtualRowAnchor(thread, anchor);
} finally {
paintedSamples = await stopVirtualRowPaintProbe(thread);
paintResult = await stopVirtualRowPaintProbe(thread);
}
expectPaintedVirtualRowAnchor(anchor, paintResult);
if (artifactDir) {
await page.screenshot({
path: path.join(artifactDir, "02-native-history-prepended-stable.png"),
fullPage: true,
});
}
expectPaintedVirtualRowAnchor(anchor, paintedSamples);
expect((await gateway.getRequests("chat.history")).at(-1)?.params).toMatchObject({
limit: 100,
offset: 100,
@@ -869,9 +929,8 @@ suite.define(() => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await page.clock.runFor(100);
await page.getByText(/^older native message 1\n/).waitFor();
await page.clock.runFor(300);
await expect.poll(() => page.locator(".chat-history-sentinel").count()).toBe(0);
expect(await page.locator(".chat-history-loading").count()).toBe(0);
expect(await gateway.getRequests("chat.history")).toHaveLength(exhaustedRequestCount);
await page.close();