diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f099ed3c958e..0010c1500f13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/ui/package.json b/ui/package.json index 257907bdec44..7bcd05526002 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/e2e/claude-sessions.e2e.test.ts b/ui/src/e2e/claude-sessions.e2e.test.ts index 113838c6794b..b21c2eba4bba 100644 --- a/ui/src/e2e/claude-sessions.e2e.test.ts +++ b/ui/src/e2e/claude-sessions.e2e.test.ts @@ -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 { +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 { return thread.evaluate(() => { const target = globalThis as typeof globalThis & { chatPrependPaintProbe?: VirtualRowPaintProbe; @@ -133,42 +172,52 @@ async function stopVirtualRowPaintProbe(thread: Locator): Promise 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();