Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0f67143
test(ui): measure transcript geometry ablations (#5184)
Astro-Han Sep 11, 2026
2426014
test(perf): compare layout costs in frontend CI (#5184)
Astro-Han Sep 11, 2026
7f83506
fix(ui): keep transcript geometry stable during reader input (#5184)
Astro-Han Sep 11, 2026
438c480
fix(ci): normalize geometry probe ASF headers
Astro-Han Sep 11, 2026
a1a6d57
fix(ui): move transcript publication into conversation ownership
Astro-Han Sep 11, 2026
564f7e5
fix(ui): preserve transcript publication action identity
Astro-Han Sep 11, 2026
92e39b0
test(ui): measure history insertion after initial markdown layout
Astro-Han Sep 11, 2026
3f7d96d
fix(ui): align publication lifecycle with renderer transcript window
Astro-Han Sep 11, 2026
f4560c0
fix(ui): preserve tail following after stationary edge input
Astro-Han Sep 12, 2026
56305b2
test(ui): synchronize geometry probes with native input settlement
Astro-Han Sep 12, 2026
e811287
fix(ui): make transcript publication an atomic scroll boundary
Astro-Han Sep 12, 2026
5688482
fix(ui): retain transcript publication beyond viewport lifetime
Astro-Han Sep 12, 2026
02954e8
fix(desktop): retain stream settlement until transcript publication
Astro-Han Sep 12, 2026
5ca2d89
test(ui): focus geometry guards and trim experiment diagnostics
Astro-Han Sep 12, 2026
212c9e4
fix(ui): hold publication until the last touch ends
Astro-Han Sep 12, 2026
d052835
fix(ui): distinguish answer accessibility scopes by conversation context
Astro-Han Sep 12, 2026
3eafd5d
fix(ui): preserve literal text in message accessibility labels
Astro-Han Sep 12, 2026
ac21650
test(ui): allow deferred markdown to settle before accessibility audit
Astro-Han Sep 12, 2026
bd2176e
test(ui): remove geometry story readiness polling
Astro-Han Sep 12, 2026
e312d0d
test(ui): remove redundant synthetic history anchor story
Astro-Han Sep 12, 2026
df419c2
fix(ui): converge input release through the idle notification gate
Astro-Han Sep 12, 2026
c2eb6b6
fix(desktop): query current message publication from its owner
Astro-Han Sep 12, 2026
447d68e
test(ui): align migrated motion story with stable geometry
Astro-Han Sep 12, 2026
f4bfcb3
test(desktop): remove frame-dependent wheel sampling
Astro-Han Sep 12, 2026
15b0e98
test(ui): wait for restored model picker focus before keyboard input
Astro-Han Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,12 @@ jobs:
if: steps.plan.outputs.storybook == 'true'
run: npm --workspace @maka/desktop run smoke:storybook

- name: Transcript geometry invariants
if: steps.plan.outputs.storybook == 'true'
env:
GEOMETRY_REPETITIONS: '1'
run: xvfb-run -a node scripts/perf/geometry-ablation.mjs --assert-stable

- name: Update stable Rust for CLI packaging
if: steps.plan.outputs.cli_package == 'true'
run: rustup update stable --no-self-update
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/performance-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ jobs:
- name: Storybook measurements
if: ${{ !cancelled() && steps.browsers.outcome == 'success' }}
run: xvfb-run -a node scripts/perf/storybook.mjs
- name: Fixed-range layout ablation
if: ${{ !cancelled() && steps.browsers.outcome == 'success' }}
run: xvfb-run -a node scripts/perf/geometry-ablation.mjs
- name: Upload raw measurements and diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@
},
"transcript-scroll-cost.spec.ts": {
"tests": 1,
"electron": "One traversal exercises real Host transcript paging through preload/IPC, bounded retained ranges, reader displacement when pages install, and a return-to-tail Host read. Chromium containment and fixture motion run in the TranscriptRenderCost story."
"electron": "One traversal exercises real Host transcript paging through preload/IPC, bounded retained ranges, reader displacement when pages install, and a return-to-tail Host read. Fixture motion runs in the TranscriptRenderCost story."
},
"scroll-geometry.spec.ts": {
"tests": 1,
"electron": "a real Host history batch arrives during a held native scrollbar drag; release must publish that range while preserving the reading Turn and allow return to latest"
},
"workhub-layout.spec.ts": {
"tests": 2,
Expand Down
234 changes: 234 additions & 0 deletions apps/desktop/e2e/scroll-geometry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

// Real native scrollbar input: stable held geometry, preserved reading anchor,
// and history progress after release. Fixed-range cold scrolling runs in CI too.
import { test, expect } from '@playwright/test';
import { withE2eWindow } from './fixtures';

test('native thumb keeps its geometry and releases history without moving the reader', async () => {
test.setTimeout(180_000);
await withE2eWindow(
{
seed: false,
readinessSelector: '[data-turn-id]',
e2eFixtureScenario: 'chat-prompt-rail',
locale: 'zh-CN',
showWindow: true,
},
async (page) => {
await page.setViewportSize({ width: 1000, height: 700 });
const cdp = await page.context().newCDPSession(page);
await page.addInitScript(() => {
const style = document.createElement('style');
style.textContent = `
[data-chat-scroll-container] { scroll-behavior:auto !important; scrollbar-width:auto !important; scrollbar-color:auto !important; }
[data-chat-scroll-container]::-webkit-scrollbar { width:14px; }
[data-chat-scroll-container]::-webkit-scrollbar-thumb { background:#777; min-height:0; border:0; }
[data-chat-scroll-container]::-webkit-scrollbar-track { background:#ddd; }`;
const append = () => document.documentElement.append(style);
if (document.documentElement) append();
else
new MutationObserver((_, observer) => {
if (document.documentElement) {
append();
observer.disconnect();
}
}).observe(document, { childList: true });
});
{
await page.reload();
await expect(page.locator('[data-turn-id]').first()).toBeVisible();
const returnLatest = page.getByRole('button', {
name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/,
});
if (await returnLatest.isVisible()) await returnLatest.click();
await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1, {
timeout: 30_000,
});
await page.evaluate(() => document.fonts.ready);
// Baseline app admission, not a geometry-settled assertion. Prefetch can
// still happen during the subsequent held drag and must be recorded.
await page.waitForTimeout(500);
const start = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>('[data-chat-scroll-container]')!;
const box = root.getBoundingClientRect();
const state = {
held: false,
done: false,
pointerDown: 0,
pointerUp: 0,
readingId: undefined as string | undefined,
frames: [] as Array<{
h: number;
t: number;
v: number;
range: string;
held: boolean;
ms: number;
anchorTop?: number;
}>,
};
(window as any).__windowGeometry = state;
root.addEventListener('pointerdown', () => {
state.pointerDown++;
state.held = true;
});
document.addEventListener('pointerup', () => {
state.pointerUp++;
state.held = false;
});
const frame = () => {
const turns = [...root.querySelectorAll<HTMLElement>('.maka-transcript-turn')];
state.frames.push({
h: root.scrollHeight,
t: root.scrollTop,
v: root.clientHeight,
range: turns.map((t) => t.dataset.transcriptTurnId).join(','),
held: state.held,
ms: performance.now(),
anchorTop: state.readingId
? root.querySelector(`[data-turn-id="${state.readingId}"]`)?.getBoundingClientRect()
.top
: undefined,
});
if (!state.done) requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
return {
x: box.right - 7,
top: box.top,
v: root.clientHeight,
h: root.scrollHeight,
t: root.scrollTop,
gutter: root.offsetWidth - root.clientWidth,
};
});
expect(start.gutter, 'a real classic scrollbar must be present').toBeGreaterThanOrEqual(12);
expect(start.h).toBeGreaterThan(start.v);
const startY = start.top + ((start.t + start.v / 2) * start.v) / start.h;
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: start.x, y: startY });
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: start.x,
y: startY,
button: 'left',
buttons: 1,
clickCount: 1,
});
for (let step = 1; step <= 40; step++) {
const y = startY + ((start.top + 8 - startY) * step) / 40;
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseMoved',
x: start.x,
y,
button: 'left',
buttons: 1,
});
await page.waitForTimeout(25);
}
await page.waitForTimeout(400);
const reading = await page.evaluate(() => {
const root = document.querySelector('[data-chat-scroll-container]')!;
const top = root.getBoundingClientRect().top;
const turn = [...root.querySelectorAll<HTMLElement>('[data-turn-id]')].find(
(el) => el.getBoundingClientRect().bottom > top,
)!;
(window as any).__windowGeometry.readingId = turn.dataset.turnId;
return { id: turn.dataset.turnId!, top: turn.getBoundingClientRect().top };
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: start.x,
y: start.top + 8,
button: 'left',
buttons: 0,
clickCount: 1,
});
// A loaded runner can deliver fewer than three frames in 300ms. Keep
// observing through the actual publication instead of stopping on time.
await page.waitForFunction(() => {
const state = (window as any).__windowGeometry;
const held = state.frames.find((frame: any) => frame.held);
const released = state.frames.filter((frame: any) => !frame.held && frame.anchorTop !== undefined);
return released.length > 2 && released.some((frame: any) => frame.range !== held.range);
}).catch(async (error) => {
await test.info().attach('scroll-geometry-frames', {
body: JSON.stringify(await page.evaluate(() => (window as any).__windowGeometry)),
contentType: 'application/json',
});
throw error;
});
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
));
const result = await page.evaluate(() => {
const state = (window as any).__windowGeometry;
state.done = true;
return state;
});
const held = result.frames.filter((f: any) => f.held);
await test.info().attach('scroll-geometry-frames', {
body: JSON.stringify(result),
contentType: 'application/json',
});
const heightDrift =
Math.max(...held.map((f: any) => f.h)) - Math.min(...held.map((f: any) => f.h));
const ranges = new Set(held.map((f: any) => f.range));
expect(result.pointerDown).toBe(1);
expect(result.pointerUp).toBe(1);
expect(heightDrift, 'height must remain constant while held').toBeLessThanOrEqual(1);
expect(ranges.size, 'resident membership must remain constant while held').toBe(1);
expect(
Math.max(0, ...held.slice(1).map((f: any, i: number) => f.t - held[i].t)),
'upward native drag must not reverse',
).toBeLessThanOrEqual(1);
const released = result.frames.filter((f: any) => !f.held && f.anchorTop !== undefined);
expect(
Math.max(...released.map((f: any) => Math.abs(f.anchorTop - reading.top))),
'reading anchor must survive every release frame',
).toBeLessThanOrEqual(1);
await expect
.poll(() =>
page
.locator('.maka-transcript-turn')
.evaluateAll((els) =>
els.map((el) => (el as HTMLElement).dataset.transcriptTurnId).join(','),
),
)
.not.toBe(held[0].range);
const anchor = page.locator('[data-turn-id="' + reading.id + '"]');
await expect(anchor).toHaveCount(1);
await expect
.poll(async () => Math.abs((await anchor.boundingBox())!.y - reading.top))
.toBeLessThanOrEqual(1);
await page
.getByRole('button', {
name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/,
})
.click();
await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1);
expect(
Math.max(...held.map((f: any) => f.t)) - Math.min(...held.map((f: any) => f.t)),
'native thumb drag must actually scroll',
).toBeGreaterThan(100);
}
},
);
});
63 changes: 35 additions & 28 deletions apps/desktop/e2e/transcript-scroll-cost.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ declare global {
interface Window {
__makaTranscriptDisplacement?: {
boundaries: TranscriptBoundary[];
record(on: boolean): void;
isSettled(): boolean;
stop(): void;
};
}
Expand Down Expand Up @@ -149,25 +149,38 @@ async function observeDisplacement(page: Page): Promise<void> {
key: [...tops.keys()].join(','),
};
};
// Only frames the reader is not scrolling through can be compared: a wheel
// tick moves every Turn on screen by its own delta, which is
// indistinguishable from a page that moved them. Which frames those are is
// told, not inferred — the gesture and the rAF that reads it land in the
// same frame in an order nothing here controls, and a reading that catches
// one tick reports exactly one tick of displacement.
// Arm in the page's native event dispatch, before the authority's deferred
// publication. Arming from Playwright after wheel() returns races the same
// rendering frames that publish the range and can miss every boundary.
let recording = false;
const record = (on: boolean): void => {
if (recording === on) return;
recording = on;
previous = read();
settled = null;
};
const onWheel = (event: Event): void => {
const { deltaY } = event as WheelEvent;
const remaining = deltaY < 0 ? scroller.scrollTop
: scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
// Edge input cannot move the viewport and may never emit scrollend.
record(remaining <= 0);
};
const onScrollEnd = (): void => record(true);
scroller.addEventListener('wheel', onWheel, { capture: true, passive: true });
scroller.addEventListener('scrollend', onScrollEnd, { capture: true });
const state: {
boundaries: unknown[];
record(on: boolean): void;
isSettled(): boolean;
stop(): void;
} = {
boundaries: [],
record: (on: boolean) => {
recording = on;
previous = read();
settled = null;
isSettled: () => recording && settled === null,
stop: () => {
running = false;
scroller.removeEventListener('wheel', onWheel, true);
scroller.removeEventListener('scrollend', onScrollEnd, true);
},
stop: () => { running = false; },
};
let running = true;
let previous = read();
Expand Down Expand Up @@ -224,15 +237,6 @@ async function observeDisplacement(page: Page): Promise<void> {
}, SCROLLER);
}

/** Opens the measurement window, or closes it around the reader's own gesture. */
async function recordDisplacement(page: Page, on: boolean): Promise<void> {
await page.evaluate((value) => {
const state = window.__makaTranscriptDisplacement;
if (!state) throw new Error('the transcript displacement probe is missing');
state.record(value);
}, on);
}

async function displacement(page: Page): Promise<readonly TranscriptBoundary[]> {
return page.evaluate(() => {
const state = window.__makaTranscriptDisplacement;
Expand Down Expand Up @@ -303,6 +307,9 @@ async function returnToLatest(page: Page): Promise<void> {
* range changes, whatever Turn the reader can still see must hold its viewport
* position.
*
* Each burst contains consecutive native wheel ticks. Publication boundaries
* are sampled after scrollend, separately from the reader's own movement.
*
* Displacement in pixels rather than frame timings on purpose — see this file's
* header for what happened to the timing assertions this suite replaced. A
* stall and a jump have the same cause here (a page boundary that moves
Expand All @@ -328,18 +335,18 @@ test('Host history paging stays bounded, preserves the reader and returns to lat
if (firstBefore === 'turn-prompt-rail-1') break;
await expect
.poll(async () => {
await recordDisplacement(page, false);
await wheel(page, cdp, { ticks: 12, deltaY: -120 });
// The hand comes off the wheel here. A page requested by the gesture
// lands in the quiet that follows — which is also when a reader would
// see it move — so that quiet is the whole of what is measured.
await recordDisplacement(page, true);
await page.waitForTimeout(150);
await page.waitForFunction(() => window.__makaTranscriptDisplacement?.isSettled());
return turns.first().getAttribute('data-turn-id');
})
.not.toBe(firstBefore);
pages += 1;
mountedMax = Math.max(mountedMax, await turns.count());
// Let the probe compare the changed range with its next rendered frame
// before another wheel closes the measurement interval.
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
));
}

expect(pages).toBeGreaterThan(0);
Expand Down
Loading