mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): keep native history anchor stable (#123103)
This commit is contained in:
committed by
GitHub
parent
600126acaa
commit
7a7f6dfbd4
Generated
+11
-11
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user