Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6777c7b
test(desktop): add bounded history scroll reproduction
Astro-Han Sep 13, 2026
839d145
test(perf): measure scroll extent and real Host input publication
Astro-Han Sep 13, 2026
0039a23
perf(ui): experiment with measured space across transcript eviction
Astro-Han Sep 13, 2026
a5e4f2a
test(perf): pair scroll variants on one runner with lightweight probes
Astro-Han Sep 13, 2026
c3eed68
test(perf): isolate transcript page admission and layout costs
Astro-Han Sep 13, 2026
b65a8c1
fix(ui): mount transcript bodies only near the viewport
Astro-Han Sep 13, 2026
e58d0fa
test(perf): compare viewport mounting at the original page budget
Astro-Han Sep 13, 2026
ee8a9d3
test(ui): bound continuity traversal and retain failure evidence
Astro-Han Sep 13, 2026
d8e0c7f
test(ui): honor the simulated history boundary
Astro-Han Sep 13, 2026
2f1113f
test(ui): bound virtual history continuity fixture
Astro-Han Sep 13, 2026
d79e310
fix(ui): publish virtual history during active scrolling
Astro-Han Sep 13, 2026
85a2daa
test(perf): cover continuous history publication and blank frames
Astro-Han Sep 13, 2026
002a08f
fix(ui): anchor virtual history to measured content
Astro-Han Sep 13, 2026
1d563f4
test(perf): verify virtual history after viewport resizing
Astro-Han Sep 13, 2026
4b43c54
test(perf): measure visible text instead of estimated shell movement
Astro-Han Sep 13, 2026
ff3dace
test(perf): capture browser long frame attribution
Astro-Han Sep 13, 2026
d5feb4d
test(perf): attribute transcript scroll position writes
Astro-Han Sep 13, 2026
627e91b
fix(ui): finish history reads before trimming their window
Astro-Han Sep 13, 2026
82d01ed
test(perf): retire historical scroll experiment matrix
Astro-Han Sep 13, 2026
bac41d1
fix(ui): narrow ancestor selector matching during transcript scroll
Astro-Han Sep 13, 2026
3ce0c08
fix(ui): serialize transcript navigation and range reveal
Astro-Han Sep 14, 2026
c397761
fix(ui): consume latest batched row visibility
Astro-Han Sep 14, 2026
82d807b
perf(ui): use span highlighting for transcript code blocks
Astro-Han Sep 14, 2026
7f29174
perf(ui): reuse unchanged prompt rail ticks
Astro-Han Sep 14, 2026
4f91db7
fix(ui): estimate unmeasured transcript heights from visible layout
Astro-Han Sep 14, 2026
6ff56dd
fix(ui): preserve measured zero-height transcript rows
Astro-Han Sep 14, 2026
e3833c9
refactor(ui): remove duplicate reasoning text aggregate
Astro-Han Sep 14, 2026
14e8581
refactor(ui): keep processing sequences stable across tool projection
Astro-Han Sep 14, 2026
f2ac93b
fix(ui): retain measured heights across transcript jumps
Astro-Han Sep 14, 2026
0f7b535
perf(ui): defer tool group rows until first expansion
Astro-Han Sep 14, 2026
d991258
test(perf): remove superseded scroll diagnostics
Astro-Han Sep 14, 2026
4d5fae7
test(perf): require visible mounted history bodies
Astro-Han Sep 15, 2026
1a590f8
merge: integrate main into transcript viewport mounting
Astro-Han Sep 15, 2026
ffd9d1e
fix(ui): sample resolved transcript block spacing
Astro-Han Sep 15, 2026
fcb8dac
test(desktop): assert live history admission during native drag
Astro-Han Sep 15, 2026
304fb55
test(desktop): await Side Chat submission readiness
Astro-Han Sep 15, 2026
0742566
test(desktop): await mounted WorkHub conversation bodies
Astro-Han Sep 15, 2026
7489d5b
fix(ui): defer history publication while native thumb is held
Astro-Han Sep 15, 2026
ba00d2c
merge: integrate main process disclosure refinements
Astro-Han Sep 15, 2026
8cb34e4
test(desktop): await process expansion before cold scroll measurement
Astro-Han Sep 15, 2026
951a763
test(desktop): wait for fully exposed process content
Astro-Han Sep 15, 2026
4596ed4
test(desktop): isolate held publication from cold height correction
Astro-Han Sep 15, 2026
32cc1ac
fix(ui): preserve reader identity across input handoffs
Astro-Han Sep 15, 2026
f7fd102
fix(ui): anchor a bounded window of real transcript rows
Astro-Han Sep 15, 2026
8d3140d
fix(ui): preserve the reader at the scroll origin
Astro-Han Sep 15, 2026
94a4853
test(ui): simplify history continuity traversal
Astro-Han Sep 15, 2026
9b57b35
test(desktop): show native scroll geometry fixture
Astro-Han Sep 15, 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
9 changes: 7 additions & 2 deletions .github/workflows/performance-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,27 @@ permissions:
jobs:
measure:
runs-on: ubuntu-24.04
timeout-minutes: 35
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.18.1'
node-version: "24.18.1"
cache: npm
- run: npm ci
- run: npm --workspace @maka/desktop run build:workspace-deps
- run: npm --workspace @maka/desktop run build
- run: npm --workspace @maka/desktop run build-storybook
- id: browsers
run: npx playwright install --with-deps chromium
- name: Native history traversal never exposes an empty transcript
env:
MAKA_PERF_OUTPUT: ${{ github.workspace }}/perf-results/native-history
run: node scripts/perf/scroll-window.mjs
- name: Electron and Host measurements
if: ${{ !cancelled() && steps.browsers.outcome == 'success' }}
working-directory: apps/desktop
env:
MAKA_PERF_OUTPUT: ${{ github.workspace }}/perf-results
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
},
"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"
"electron": "a real Host history batch crosses preload during a native scrollbar drag; cold content must remain readable while held and after release, then return to latest reads the Host tail"
},
"workhub-layout.spec.ts": {
"tests": 2,
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import { _electron as electron, test as base, expect } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import type { ElectronApplication, Locator, Page } from '@playwright/test';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
Expand Down Expand Up @@ -80,7 +80,7 @@ export async function ensureSidebarExpanded(page: Page): Promise<void> {
* The control reflects local admission readiness, not Host connectivity.
* Merely mounting the editor does not mean target selection has finished.
*/
export async function awaitSendReady(page: Page): Promise<void> {
export async function awaitSendReady(page: Page | Locator): Promise<void> {
await expect(page.locator('.maka-composer button[type="submit"]')).toBeEnabled({
timeout: 20_000,
});
Expand Down
109 changes: 79 additions & 30 deletions apps/desktop/e2e/scroll-geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
* 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.
// Host history delivered through preload must preserve the reader throughout
// native input, including the first layout of previously unseen rows.
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('native thumb preserves a cold reader while admitting Host history', async () => {
test.setTimeout(180_000);
await withE2eWindow(
{
Expand Down Expand Up @@ -74,7 +74,9 @@ test('native thumb keeps its geometry and releases history without moving the re
done: false,
pointerDown: 0,
pointerUp: 0,
originVisits: 0,
readingId: undefined as string | undefined,
sample: undefined as number | undefined,
frames: [] as Array<{
h: number;
t: number;
Expand All @@ -83,9 +85,13 @@ test('native thumb keeps its geometry and releases history without moving the re
held: boolean;
ms: number;
anchorTop?: number;
sample?: number;
}>,
};
(window as any).__windowGeometry = state;
root.addEventListener('scroll', () => {
if (state.held && root.scrollTop === 0) state.originVisits++;
}, { capture: true });
root.addEventListener('pointerdown', () => {
state.pointerDown++;
state.held = true;
Expand All @@ -103,8 +109,9 @@ test('native thumb keeps its geometry and releases history without moving the re
range: turns.map((t) => t.dataset.transcriptTurnId).join(','),
held: state.held,
ms: performance.now(),
sample: state.sample,
anchorTop: state.readingId
? root.querySelector(`[data-turn-id="${state.readingId}"]`)?.getBoundingClientRect()
? root.querySelector(`.maka-turn[data-turn-id="${state.readingId}"]`)?.getBoundingClientRect()
.top
: undefined,
});
Expand Down Expand Up @@ -132,31 +139,78 @@ test('native thumb keeps its geometry and releases history without moving the re
buttons: 1,
clickCount: 1,
});
for (let step = 1; step <= 40; step++) {
const y = startY + ((start.top + 8 - startY) * step) / 40;
const visible = (sample?: number, anchorId?: string) => page.evaluate(({ sample, anchorId }) => {
const root = document.querySelector('[data-chat-scroll-container]')!;
const view = root.getBoundingClientRect();
const turn = anchorId
? root.querySelector<HTMLElement>(`.maka-turn[data-turn-id="${anchorId}"]`)
: [...root.querySelectorAll<HTMLElement>('.maka-turn[data-turn-id]')].find((el) => {
const box = el.getBoundingClientRect();
return box.height > 0 && box.width > 0 && box.bottom > view.top && box.top < view.bottom;
});
if (sample !== undefined) {
(window as any).__windowGeometry.sample = sample;
(window as any).__windowGeometry.readingId = turn?.dataset.turnId;
}
return {
id: turn?.dataset.turnId, top: turn?.getBoundingClientRect().top,
scrollHeight: root.scrollHeight, scrollTop: root.scrollTop,
};
}, { sample, anchorId });
const stationary: Array<{ before: Awaited<ReturnType<typeof visible>>; after: Awaited<ReturnType<typeof visible>> }> = [];
const positions = [10, 6, 10, 4];
for (let step = 1; step <= positions.length; step++) {
await page.evaluate(() => { (window as any).__windowGeometry.sample = undefined; });
const progress = positions[step - 1];
// Cross the real origin, where native anchoring is unavailable,
// before reversing the same held thumb.
const y = startY + ((start.top + 1 - startY) * progress) / 10;
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseMoved',
x: start.x,
y,
button: 'left',
buttons: 1,
});
await page.waitForTimeout(25);
await page.waitForTimeout(50);
const before = await visible(step);
await page.waitForTimeout(300);
const after = await visible(undefined, before.id);
stationary.push({ before, after });
}
await page.waitForTimeout(400);
await test.info().attach('held-reader-samples', {
body: JSON.stringify(stationary), contentType: 'application/json',
});
for (const [index, { before, after }] of stationary.entries()) {
expect(before.id, 'the held viewport must contain a rendered Turn').toBeTruthy();
expect(after.id, 'a stationary pointer must not replace the reader').toBe(before.id);
expect(Math.abs(after.top! - before.top!), 'the held reader must stay in place').toBeLessThanOrEqual(1);
if (index > 0) {
const previous = Number(stationary[index - 1].after.id!.split('-').at(-1));
const current = Number(before.id!.split('-').at(-1));
if (positions[index] > positions[index - 1]) expect(current, 'upward input must not move toward newer Turns').toBeLessThanOrEqual(previous);
else expect(current, 'reversing input must move toward newer Turns').toBeGreaterThanOrEqual(previous);
}
}
const reading = await page.evaluate(() => {
(window as any).__windowGeometry.sample = undefined;
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,
)!;
const viewport = root.getBoundingClientRect();
const turn = [...root.querySelectorAll<HTMLElement>('.maka-turn[data-turn-id]')].find(
(el) => {
const box = el.getBoundingClientRect();
return box.height > 0 && box.width > 0 && box.bottom > viewport.top && box.top < viewport.bottom;
},
);
if (!turn) throw new Error('Native drag left no rendered reading Turn');
(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,
y: startY + (start.top + 1 - startY) * 0.4,
button: 'left',
buttons: 0,
clickCount: 1,
Expand All @@ -183,37 +237,32 @@ test('native thumb keeps its geometry and releases history without moving the re
state.done = true;
return state;
});
expect(result.originVisits, 'the held thumb must exercise the scroll origin').toBeGreaterThan(0);
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));
for (const [index, { before }] of stationary.entries()) {
const frames = held.filter((f: any) => f.sample === index + 1);
expect(frames.length, 'every stationary hold must be observed across frames').toBeGreaterThan(1);
for (const frame of frames) {
expect(frame.anchorTop, 'the held reader must remain mounted').toBeDefined();
expect(Math.abs(frame.anchorTop - before.top!), 'every stationary frame retains the reading line').toBeLessThanOrEqual(1);
}
}
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);
// Prepending Host pages changes both height and scrollTop. Neither is
// a reader-displacement metric; measure the rendered Turn on release.
expect(ranges.size, 'the hold must exercise actual Host history publication').toBeGreaterThan(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 + '"]');
const anchor = page.locator('.maka-turn[data-turn-id="' + reading.id + '"]');
await expect(anchor).toHaveCount(1);
await expect
.poll(async () => Math.abs((await anchor.boundingBox())!.y - reading.top))
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/e2e/side-chat-followups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect',
const companion = page.locator('.maka-quote-companion');
const sideComposer = companion.locator(COMPOSER_INPUT);
await sideComposer.fill(FAKE_HOLD_OPEN_PROMPT);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(companion).toContainText('Fake backend waiting');
const forkId = await page.evaluate(async (existingIds) => {
Expand All @@ -106,6 +107,8 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect',
const queued = companion.locator('.maka-composer-queue');
for (const text of ['first follow-up', 'second follow-up', 'retract this follow-up']) {
await sideComposer.fill(text);
// The queue is optimistic; its appearance does not settle send admission.
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(queued).toContainText(text);
}
Expand All @@ -127,6 +130,7 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect',
await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible();

await sideComposer.fill('steer the current response');
await awaitSendReady(companion);
await sideComposer.press('Shift+Enter');
await expect(companion).toContainText('Acknowledged steering: steer the current response');
await expect(queued.locator('.maka-composer-queue-text')).toHaveText([
Expand All @@ -147,15 +151,18 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect',
// Hold a second Turn before its first token, queue two successors, then
// release it by steering. All three replies must survive the Host handoffs.
await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible();
for (const text of ['successor one', 'successor two']) {
await sideComposer.fill(text);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(queued).toContainText(text);
}
await page.screenshot({ path: testInfo.outputPath('side-chat-queue.png'), fullPage: true });
await sideComposer.fill('release the held response');
await awaitSendReady(companion);
await sideComposer.press('Shift+Enter');
await expect(companion).toContainText('Acknowledged steering: release the held response');
await expect(companion).toContainText('Fake backend received: successor one', { timeout: 20_000 });
Expand All @@ -165,10 +172,12 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect',
await page.screenshot({ path: testInfo.outputPath('side-chat-settled.png'), fullPage: true });

await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible();
for (const text of ['reconnected successor one', 'reconnected successor two']) {
await sideComposer.fill(text);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(queued).toContainText(text);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@
"react": 1
},
"importSpecifiers": 103,
"nonTriviaTokens": 13381
"nonTriviaTokens": 13368
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ describe('app shell session UI state controller', () => {
store: {
sessionId: 'session',
range: () => ({ sessionId: 'session' }),
pendingNavigation: () => undefined,
sequenceForTurn: () => 17,
newestDurableUserSequence: () => 17,
snapshot: () => ({ messages: [] }),
Expand Down Expand Up @@ -291,6 +292,7 @@ describe('app shell session UI state controller', () => {
store: {
sessionId: 'stale',
range: () => ({ sessionId: 'stale' }),
pendingNavigation: () => undefined,
sequenceForTurn: () => {
sequenceReads += 1;
return 17;
Expand Down Expand Up @@ -322,6 +324,7 @@ describe('app shell session UI state controller', () => {
store: {
sessionId: 'session',
range: () => ({ sessionId: 'session' }),
pendingNavigation: () => undefined,
sequenceForTurn: () => null,
newestDurableUserSequence: () => null,
snapshot: () => ({ messages: [] }),
Expand Down Expand Up @@ -357,6 +360,7 @@ describe('app shell session UI state controller', () => {
store: {
sessionId: 'session',
range: () => ({ sessionId: 'session' }),
pendingNavigation: () => undefined,
sequenceForTurn: () => null,
newestDurableUserSequence: () => 29,
snapshot: () => ({ messages: [{ id: 'latest' }] }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1329,7 +1329,11 @@ test('a fill is issued once per window and again as soon as the window moves', a
sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1',
readThroughMessageId: null,
async acknowledgeTail() {},
async loadBefore() { reads += 1; },
async loadBefore(_anchor, maxBytes) {
assert.equal(maxBytes, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES,
'a background projection keeps its existing scan budget');
reads += 1;
},
async loadAfter() {}, async loadAround() {}, async loadLatest() {}, async close() {},
}));
for (const batch of encodeDesktopTranscriptSnapshot({
Expand All @@ -1355,11 +1359,12 @@ test('reports each tail the window reaches once, and none while it is parked', a
const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' };
const store = transcriptStore();
const acknowledged: number[] = [];
const readBudgets: number[] = [];
// The visible reader path: only a controller that acknowledges reports a tail.
const controller = createRecoveringDesktopTranscriptRangeController(store, async () => ({
...identity, readThroughMessageId: null,
async acknowledgeTail(through) { acknowledged.push(through); },
async loadBefore() {}, async loadAfter() {}, async loadAround() {},
async loadBefore(_anchor, maxBytes) { readBudgets.push(maxBytes); }, async loadAfter() {}, async loadAround() {},
async loadLatest() {}, async close() {},
}), { onError() {} });
const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
Expand All @@ -1371,6 +1376,10 @@ test('reports each tail the window reaches once, and none while it is parked', a
})) store.accept(batch);
await settle();
assert.deepEqual(acknowledged, [1]);
await controller.loadBefore();
assert.equal(readBudgets.length, 1);
assert.ok(readBudgets[0]! > 0 && readBudgets[0]! < DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES,
'the visible reader uses an interactive page budget rather than the IPC ceiling');

for (const batch of encodeDesktopTranscriptChange(identity, {
coversFrom: 1, durableThrough: 2,
Expand Down
Loading