From 0f67143c8ddb02760cd3d601d2add271e927d2c1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 15:24:55 +0800 Subject: [PATCH 01/25] test(ui): measure transcript geometry ablations (#5184) --- apps/desktop/stories/app-shell.stories.tsx | 29 ++ scripts/perf/geometry-ablation.mjs | 304 +++++++++++++++++++++ scripts/perf/geometry-results.md | 132 +++++++++ scripts/perf/geometry-window.config.ts | 24 ++ scripts/perf/window-geometry.spec.ts | 185 +++++++++++++ 5 files changed, 674 insertions(+) create mode 100644 scripts/perf/geometry-ablation.mjs create mode 100644 scripts/perf/geometry-results.md create mode 100644 scripts/perf/geometry-window.config.ts create mode 100644 scripts/perf/window-geometry.spec.ts diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 9555a69344..83dbd09c15 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2615,6 +2615,35 @@ export const Performance45Tools: Story = { render: () => , }; +// Fixed membership: geometry probes must not mistake history paging for lazy +// layout. Mixed prose and long 100+ line CodeBlocks exercise all three +// skipping boundaries (Turn, timeline block, Astryx line chunk). +export const GeometryMixed24Turns: Story = { + render: () => { + const turnId = `geometry-${i}`; + const prose = Array.from({ length: 4 + (i % 5) * 3 }, (_, p) => + `第 ${i + 1} 轮,第 ${p + 1} 段。${'固定内容用于检查首次上滚时的文档尺寸,不发生流式输出或历史分页。'.repeat(3)}`, + ).join('\n\n'); + const code = i % 6 === 0 + ? '\n\n```text\n' + Array.from({ length: 140 }, (_, line) => + `${line + 1}: ${'wrapped-code-content-'.repeat(9)}`, + ).join('\n') + '\n```' + : ''; + return [user(`geometry-u-${i}`, turnId, 50 - i, `检查第 ${i + 1} 组。`), + assistant(`geometry-a-${i}`, turnId, 50 - i, prose + code)]; + }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, +}; + +export const GeometryLongCode: Story = { + render: () => + `${line + 1}: ${'wrapped-code-content-'.repeat(9)}`, + ).join('\n') + '\n```'), + ], hasOlderHistory: false, hasNewerHistory: false }} />, +}; + export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { render: () => , play: async () => { diff --git a/scripts/perf/geometry-ablation.mjs b/scripts/perf/geometry-ablation.mjs new file mode 100644 index 0000000000..29dddd76e0 --- /dev/null +++ b/scripts/perf/geometry-ablation.mjs @@ -0,0 +1,304 @@ +/* + * 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. + */ + +// Manual diagnostic, not a passing substitute for the future geometry CI +// gate. One Electron process, fresh DOM per trial, alternating configurations. +// Uses production ComposedShell stories; no Host, paging or streaming here. +import { fileURLToPath } from 'node:url'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +if (process.versions.electron) { + const { app, BrowserWindow } = await import('electron'); + // Electron waits for ESM evaluation before ready: awaiting ready at module + // scope would deadlock startup. + void app.whenReady().then(async () => { + const window = new BrowserWindow({ + width: 1200, + height: 900, + show: true, + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + }, + }); + await window.loadURL('about:blank'); + }); +} else { + const { _electron, expect } = await import('@playwright/test'); + const { startStaticServer } = await import('../storybook-visual-smoke.mjs'); + const server = await startStaticServer('apps/desktop/storybook-static'); + let app; + const output = path.resolve(process.env.GEOMETRY_OUTPUT ?? 'perf-results/geometry-ablation.json'); + const repetitions = Number(process.env.GEOMETRY_REPETITIONS ?? 3); + const scenes = [ + ['geometry-mixed-24-turns', 24], + ['performance-45-tools', 1], + ['geometry-long-code', 1], + ].filter(([id]) => !process.env.GEOMETRY_SCENE || id === process.env.GEOMETRY_SCENE); + const modes = ['baseline', 'no-turn-skip', 'no-skip'].filter( + (mode) => !process.env.GEOMETRY_MODE || mode === process.env.GEOMETRY_MODE, + ); + if (!scenes.length || !modes.length || !Number.isInteger(repetitions) || repetitions < 1) { + await server.close(); + throw new Error('Invalid geometry scene, mode or repetition count'); + } + const rows = []; + try { + app = await _electron.launch({ args: [fileURLToPath(import.meta.url)], timeout: 30_000 }); + const page = await app.firstWindow(); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'reduce' }); + const cdp = await page.context().newCDPSession(page); + const browser = await cdp.send('Browser.getVersion'); + await cdp.send('Performance.enable'); + await page.addInitScript(() => { + const mode = new URL(location.href).searchParams.get('ablation'); + const style = document.createElement('style'); + const turn = '.maka-transcript-turn'; + // The inline intrinsic-size declaration belongs to Astryx CodeChunk. + const boundaries = `${turn}, [data-maka-transcript-boundary], .astryx-codeblock [style*="contain-intrinsic-block-size"]`; + style.textContent = `*, *::before, *::after { transition:none !important; animation:none !important; } + [data-chat-scroll-container] { scroll-behavior:auto !important; } + ${mode === 'no-turn-skip' ? turn : mode === 'no-skip' ? boundaries : ':not(*)'} { + content-visibility:visible !important; + contain:layout style paint !important; + }`; + const attach = () => { + if (document.documentElement) document.documentElement.append(style); + }; + if (document.documentElement) attach(); + else + new MutationObserver((_, observer) => { + if (document.documentElement) { + attach(); + observer.disconnect(); + } + }).observe(document, { childList: true }); + const probe = (window.__geometry = { + frames: [], + tasks: [], + loaf: [], + phase: 'mount', + firstRootMs: null, + }); + new PerformanceObserver((list) => + probe.tasks.push( + ...list + .getEntries() + .map((e) => ({ start: e.startTime, duration: e.duration, phase: probe.phase })), + ), + ).observe({ type: 'longtask', buffered: true }); + if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) { + new PerformanceObserver((list) => + probe.loaf.push( + ...list + .getEntries() + .map((e) => ({ start: e.startTime, duration: e.duration, phase: probe.phase })), + ), + ).observe({ type: 'long-animation-frame', buffered: true }); + } + const frame = () => { + const root = document.querySelector('[data-chat-scroll-container]'); + if (root) { + probe.firstRootMs ??= performance.now(); + probe.frames.push({ + ms: performance.now(), + h: root.scrollHeight, + t: root.scrollTop, + v: root.clientHeight, + phase: probe.phase, + }); + } + if (probe.phase !== 'done') requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + }); + const paint = () => + page.evaluate( + () => + new Promise((resolve) => { + let frames = 4; + const step = () => (--frames ? requestAnimationFrame(step) : resolve()); + requestAnimationFrame(step); + }), + ); + const metrics = () => + page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]'); + return { + h: root.scrollHeight, + t: root.scrollTop, + v: root.clientHeight, + count: root.querySelectorAll('.maka-transcript-turn').length, + }; + }); + for (const [scene, turns] of scenes) { + for (let trial = 0; trial < repetitions; trial++) { + // Rotate the order so a cold module cache cannot always favour one mode. + for (const mode of [ + ...modes.slice(trial % modes.length), + ...modes.slice(0, trial % modes.length), + ]) { + await page.goto( + `${server.baseUrl}/iframe.html?id=product-shell-official-appshell--${scene}&viewMode=story&ablation=${mode}`, + ); + await expect(page.locator('.maka-transcript-turn')).toHaveCount(turns); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('.maka-markdown-pending')).toHaveCount(0); + await expect + .poll(async () => { + const m = await metrics(); + return m.h - m.v - m.t; + }) + .toBeLessThanOrEqual(4); + const initial = await metrics(); + expect(initial.h).toBeGreaterThan(initial.v * 3); + const start = await page.evaluate(() => { + window.__geometry.phase = 'cold-up'; + return performance.now(); + }); + const beforeCpu = await cdp.send('Performance.getMetrics'); + const box = await page.locator('[data-chat-scroll-container]').boundingBox(); + const steps = []; + const sweep = async (phase, deltaY) => { + await page.evaluate((phase) => { + window.__geometry.phase = phase; + }, phase); + for (let tick = 0; tick < 350; tick++) { + const before = await metrics(); + if (deltaY < 0 ? before.t <= 1 : before.h - before.v - before.t <= 1) return; + // elementFromPoint selects only an already visible leaf. Never + // measure the offscreen descendants to find an anchor. + await page.evaluate( + ({ x, y }) => { + const el = document.elementFromPoint(x, y); + window.__anchor = { el, top: el?.getBoundingClientRect().top }; + }, + { x: box.x + box.width / 2, y: box.y + box.height / 2 }, + ); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: box.x + box.width / 2, + y: box.y + box.height / 2, + deltaX: 0, + deltaY, + }); + await paint(); + const after = await metrics(); + const moved = await page.evaluate(() => { + const a = window.__anchor; + return a.el?.isConnected ? a.el.getBoundingClientRect().top - a.top : null; + }); + steps.push({ phase, tick, before, after, moved }); + expect(after.count, 'fixed fixture membership changed').toBe(turns); + } + throw new Error( + `${scene}/${mode}/${phase} did not reach the edge within 350 wheel ticks`, + ); + }; + await sweep('cold-up', -600); + await sweep('warm-down', 600); + await sweep('warm-up', -600); + const afterCpu = await cdp.send('Performance.getMetrics'); + await page.evaluate(() => { + window.__geometry.phase = 'done'; + }); + const state = await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]'); + return { + ...window.__geometry, + liveNodes: root.querySelectorAll('*').length, + remainingAuto: [...root.querySelectorAll('*')].filter( + (el) => getComputedStyle(el).contentVisibility === 'auto', + ).length, + }; + }); + if (mode === 'no-skip') expect(state.remainingAuto, 'missed a lazy boundary').toBe(0); + const up = state.frames.filter((f) => f.phase === 'cold-up'); + const heights = [initial.h, ...up.map((f) => f.h)]; + const maxReverse = Math.max(0, ...up.slice(1).map((f, i) => f.t - up[i].t)); + const metric = (list, name) => list.metrics.find((m) => m.name === name)?.value ?? 0; + const row = { + scene, + mode, + trial, + initial, + readyMs: start, + firstRootMs: state.firstRootMs, + heightDrift: Math.max(...heights) - Math.min(...heights), + maxReverse, + maxTaskMs: Math.max(0, ...state.tasks.map((t) => t.duration)), + scrollMaxTaskMs: Math.max( + 0, + ...state.tasks.filter((t) => t.start >= start).map((t) => t.duration), + ), + layoutMs: + (metric(afterCpu, 'LayoutDuration') - metric(beforeCpu, 'LayoutDuration')) * 1000, + heap: await cdp.send('Runtime.getHeapUsage'), + steps, + ...state, + }; + rows.push(row); + await mkdir(path.dirname(output), { recursive: true }); + await writeFile( + output, + JSON.stringify( + { + browser, + viewport: '1200x900', + repetitions, + conditions: + 'Same Electron; alternating fresh DOMs; fonts and Markdown ready; no offscreen box reads before traversal; real CDP wheel. Containment preserved; only skipping removed. Synthetic fixed-range production stories, no Host or paging.', + rows, + }, + null, + 2, + ), + ); + console.log( + JSON.stringify({ + scene, + mode, + trial, + heightDrift: row.heightDrift, + maxReverse, + readyMs: Math.round(start), + maxTaskMs: row.maxTaskMs, + scrollMaxTaskMs: row.scrollMaxTaskMs, + layoutMs: Math.round(row.layoutMs), + }), + ); + if (process.argv.includes('--assert-stable')) { + expect( + row.heightDrift, + `${scene}/${mode}: fixed-range height drift`, + ).toBeLessThanOrEqual(1); + expect(maxReverse, `${scene}/${mode}: upward scroll reversed`).toBeLessThanOrEqual(1); + } + } + } + } + console.log(`Report: ${output}`); + } finally { + await app?.close(); + await server.close(); + } +} diff --git a/scripts/perf/geometry-results.md b/scripts/perf/geometry-results.md new file mode 100644 index 0000000000..4e96d97d6b --- /dev/null +++ b/scripts/perf/geometry-results.md @@ -0,0 +1,132 @@ +# #5184 geometry ablation — 2026-09-11 + +This is a diagnostic result, not a production fix or a claim of a universal +performance bound. Production CSS and window policy were not modified. + +## Conditions + +- Fixed-range source: `a49ba7544c3bdd1ef648ec90c69b8f39f3e881c0`, with two new + deterministic ComposedShell stories and the diagnostic driver. +- Window comparison: the same main source and #5170 at + `770b2713d7110b145ccc367f1c24c2f0fc30ca04` (not an assertion about future heads). +- Apple M5 Pro, 64 GiB RAM, macOS arm64; Electron 43.4.1, + Chromium 150.0.7871.224. +- Fixed-range trial viewport 1200×900. Three scenarios × three modes × three + alternating repetitions in one Electron process, fresh DOM each time. Fonts + and Markdown ready before traversal; this is not disk-cold application startup. +- Real CDP wheel, 600px per tick; cold upward traversal, downward return and + second upward traversal. Root metrics sampled every rendering frame. No + offscreen descendant bounding-box reads to prepare the fixture. +- Ablations preserve `contain: layout style paint`; they remove skipping, not + all containment. No product toggle was added. + +## Fixed-range results + +Values below repeat identically across all three geometry trials. Height drift +is max minus min during the cold climb, not just final minus initial. Positive +upward reversal is the largest frame-to-frame increase in scrollTop. + +| Scenario | Mode | Height drift px | Upward reversal px | Ready median ms | Largest task, range ms | +| --- | --- | ---: | ---: | ---: | ---: | +| Mixed 24 Turns | Current | 20862 | 169.5 | 568 | 89–122 | +| Mixed 24 Turns | No Turn skipping | 21762 | 289.5 | 544 | 106–107 | +| Mixed 24 Turns | No skipping | 0 | 0 | 539 | 98–100 | +| 45-tool Turn | Current | 8002 | 25.5 | 520 | 71–75 | +| 45-tool Turn | No Turn skipping | 8002 | 25.5 | 515 | 74–80 | +| 45-tool Turn | No skipping | 0 | 0 | 499 | 76–80 | +| 1200-line code | Current | 2638 | 0 | 512 | 66–70 | +| 1200-line code | No Turn skipping | 2638 | 0 | 500 | 63–67 | +| 1200-line code | No skipping | 0 | 0 | 505 | 61–66 | + +All modes converge to identical final document heights: 28060, 9690 and 24413px +respectively, and identical DOM element counts (2878, 2313, 1576). Removing +skipping did not make the result stable by cutting out content. The no-skipping +probe also verifies zero remaining `content-visibility:auto` descendants. + +No >50ms task was observed during the measured scroll phases in any mode. +All modes had >50ms mount tasks. Ready timings include driver readiness polling +and loading, so small differences are not claimed as product speedups. Raw heap +samples contain uncollected objects and do not establish leak or memory bounds. +The separate strict green run observed a 144ms mount task; the table's maximum +is a sample result, not a performance upper bound. +Paint cost on weaker devices, very large highlighted code, asynchronous media, +font/width changes and ongoing streaming are not settled by these samples. + +## Real Host/native thumb drag + +120-Turn existing `chat-prompt-rail` fixture, 1000×700; baseline / no-skipping / +no-skipping / baseline in one app. A styled 14px **native** scrollbar is used to +avoid platform overlay ambiguity, with a gutter assertion and real CDP pointer +press/move/release. Every run must actually scroll >100px. Membership and H are +sampled while the pointer remains held, including 400ms without movement. + +| Source | Mode | Held height drift px, two trials | Distinct held ranges | +| --- | --- | --- | --- | +| main | Current | 134 / 118 | 4 / 5 | +| main | No skipping | 180 / 180 | 4 / 4 | +| #5170 | Current | 25268 / 25330 | 3 / 3 | +| #5170 | No skipping | 19363 / 16500 | 9 / 23 | + +This establishes that removing lazy estimates cannot make native thumb geometry +constant while the window changes membership. It does **not** quantify perceived +content jumps or prove an anchor-restoration implementation correct. A passing +window diagnostic means the measurement and input worked, not that the product +satisfies the strict geometry contract. + +## Architecture decision + +1. Do not build a Size Index, staging renderer, fixed shell or geometry snapshot + system on the strength of the original proposal. First implementation candidate + is ordinary layout inside the existing resident range, retaining only proven + containment needs. There is no measured requirement for a size manager yet. +2. Resolve all three skip sites: Turn, timeline block and Astryx CodeChunk. + Prefer the existing dependency/component seam over generated StyleX class + names. The experiment's broad override is not production code. +3. Keep window membership and scroll authority separate. #5170 still needs a + submission rule during held native scrollbar dragging. Strict H/monotonicity + for all incremental gestures also means deferring fill/trim, with edge waiting; + the experiment does not authorize relaxing that requirement. +4. Preserve a fixed-range constant-height/monotonicity regression and a distinct + window-drag contract. Do not classify a new window revision as an exemption + from a held-gesture guarantee. + +The diagnostic has an executable strict gate (`--assert-stable`): current mixed +content fails with 20862px drift; the no-skipping intervention passes all three +scenarios. This is not yet wired into ordinary product CI, because this stage +changes no production behavior. Integration of the actual fix must install the +default production-mode gate and replace overlapping weaker assertions. + +## Reproduce + +From repository root, after installing dependencies: + +```sh +npm --workspace @maka/core run build +npm --workspace @maka/desktop run build-storybook +node scripts/perf/geometry-ablation.mjs +``` + +Explicit negative control (expected to fail on the measured source): + +```sh +GEOMETRY_REPETITIONS=1 GEOMETRY_MODE=baseline GEOMETRY_SCENE=geometry-mixed-24-turns node scripts/perf/geometry-ablation.mjs --assert-stable +``` + +Intervention with the same strict assertions: + +```sh +GEOMETRY_REPETITIONS=1 GEOMETRY_MODE=no-skip node scripts/perf/geometry-ablation.mjs --assert-stable +``` + +For the Host/window diagnostic, build Desktop first, then run from +`apps/desktop` (the existing fixture resolves its app root from the working +directory): + +```sh +npm run build:with-deps +npx playwright test --config ../../scripts/perf/geometry-window.config.ts +``` + +The window report is `apps/desktop/perf-results/window-geometry.json`. Fixed +range reports default to repository `perf-results/geometry-ablation.json` and +can be directed with `GEOMETRY_OUTPUT`. Preserve reports before a subsequent run. diff --git a/scripts/perf/geometry-window.config.ts b/scripts/perf/geometry-window.config.ts new file mode 100644 index 0000000000..36c0cce964 --- /dev/null +++ b/scripts/perf/geometry-window.config.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ +import { defineConfig } from '@playwright/test'; +import base from './playwright.config'; + +// Explicit diagnostic lane: do not silently add a native-input experiment to +// the established frontend benchmark invocation. +export default defineConfig({ ...base, testMatch: 'window-geometry.spec.ts' }); diff --git a/scripts/perf/window-geometry.spec.ts b/scripts/perf/window-geometry.spec.ts new file mode 100644 index 0000000000..acbd6f8cb8 --- /dev/null +++ b/scripts/perf/window-geometry.spec.ts @@ -0,0 +1,185 @@ +/* + * 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. + */ + +// Manual evidence probe on the real Host/window path, deliberately separate +// from fixed-membership geometry. A pass means input/measurement worked, not +// that the reported height/range changes satisfy the future product contract. +import { test, expect } from '@playwright/test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { withE2eWindow } from '../../apps/desktop/e2e/fixtures'; + +test('record native thumb drag through transcript window changes', async () => { + test.setTimeout(180_000); + const rows: unknown[] = []; + 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 mode = sessionStorage.getItem('geometry-mode'); + 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; } + ${mode === 'no-skip' ? '.maka-transcript-turn, [data-maka-transcript-boundary], .astryx-codeblock [style*="contain-intrinsic-block-size"]' : ':not(*)'} { + content-visibility:visible !important; contain:layout style paint !important; + }`; + const append = () => document.documentElement.append(style); + if (document.documentElement) append(); + else + new MutationObserver((_, observer) => { + if (document.documentElement) { + append(); + observer.disconnect(); + } + }).observe(document, { childList: true }); + }); + for (const mode of ['baseline', 'no-skip', 'no-skip', 'baseline']) { + await page.evaluate((mode) => sessionStorage.setItem('geometry-mode', mode), mode); + 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('[data-chat-scroll-container]')!; + const box = root.getBoundingClientRect(); + const state = { + held: false, + done: false, + pointerDown: 0, + frames: [] as Array<{ + h: number; + t: number; + v: number; + range: string; + held: boolean; + ms: number; + }>, + }; + (window as any).__windowGeometry = state; + root.addEventListener('pointerdown', () => state.pointerDown++); + const frame = () => { + const turns = [...root.querySelectorAll('.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(), + }); + 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 page.evaluate(() => { + (window as any).__windowGeometry.held = true; + }); + 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); + await page.screenshot({ path: path.resolve(`perf-results/window-held-${mode}.png`) }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: start.x, + y: start.top + 8, + button: 'left', + buttons: 0, + clickCount: 1, + }); + await page.evaluate(() => { + (window as any).__windowGeometry.held = false; + }); + await page.waitForTimeout(300); + 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); + 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( + Math.max(...held.map((f: any) => f.t)) - Math.min(...held.map((f: any) => f.t)), + 'native thumb drag must actually scroll', + ).toBeGreaterThan(100); + rows.push({ mode, start, heightDrift, heldRanges: ranges.size, ...result }); + await mkdir('perf-results', { recursive: true }); + await writeFile('perf-results/window-geometry.json', JSON.stringify({ rows }, null, 2)); + console.log( + JSON.stringify({ + mode, + heightDrift, + heldRanges: ranges.size, + pointerDown: result.pointerDown, + }), + ); + } + }, + ); +}); From 2426014c846d49f0d09f83f1b316c16b0bdd06b4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 15:52:45 +0800 Subject: [PATCH 02/25] test(perf): compare layout costs in frontend CI (#5184) --- .github/workflows/performance-frontend.yml | 3 + scripts/perf/CI.md | 28 +++ scripts/perf/geometry-ablation.mjs | 35 ++++ scripts/perf/geometry-navigation.spec.ts | 200 +++++++++++++++++++++ scripts/perf/playwright.config.ts | 2 +- 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 scripts/perf/geometry-navigation.spec.ts diff --git a/.github/workflows/performance-frontend.yml b/.github/workflows/performance-frontend.yml index c1ce145005..1248ee945b 100644 --- a/.github/workflows/performance-frontend.yml +++ b/.github/workflows/performance-frontend.yml @@ -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 diff --git a/scripts/perf/CI.md b/scripts/perf/CI.md index 67da8a7fc8..18ea53d324 100644 --- a/scripts/perf/CI.md +++ b/scripts/perf/CI.md @@ -39,6 +39,34 @@ Protocol `warm-projection-codec-ms` retains production wire decoding but exclude - DOM-ready latency includes Playwright polling/IPC. Stream lag starts at renderer subscription delivery and ends at DOM mutation; neither is proof of pixels presented. These are laboratory measurements, not whole-page INP. Long tasks and CPU TaskDuration do not substitute for anchor geometry, energy watts or OS wakeups. - Repeated actions use a warm process. First-action samples are labelled separately, not called cold application startup. Storybook reloads for cold-scroll trials, then repeats expansion and closure in the same mounted story. Heap readings include garbage awaiting collection and do not establish a leak by themselves. Idle retains ten approximately one-second CPU/heap samples and the CPU profile; task duration is normalized by the actual CDP timestamp interval. Browser-context traces retain DOM snapshots, and their overhead is included in the laboratory timings. Long-task/LoAF rows labelled case-total include every operation after observer installation in that case. Actual disk-cold startup, RTT injection, physical presentation, wakeups and long-duration leak acceptance remain outside this first harness. +## #5184 layout ablation + +The frontend lane also runs the ordinary-layout candidate against current CSS, +without changing product behavior. `geometry-navigation.spec.ts` reloads six +renderer documents in one Desktop + Host process, alternating baseline and +no-skipping configurations (three each). It measures renderer mount, older +history and return to latest separately, including CDP task/layout time and +long tasks. The long-task observer has a deliberate busy-task control after +measurement. Mount is a warm-process document reload, not disk-cold startup; +history uses the existing prompt rail, not wheel-triggered fill/trim. + +`node scripts/perf/geometry-ablation.mjs` runs the three fixed-range stories +(mixed 24 turns, 45 tools, 1200-line code), three configurations and three +rotating repetitions. Its mount CPU/layout counters are recorded before the +first upward sweep, so deferred work cannot disappear from the comparison. +The script emits the shared report format and full per-frame geometry JSON. +Both probes run sequentially in the existing frontend job and upload through +its existing artifact step. No additional workflow or production switch exists. + +Reports are `frontend-geometry-navigation.{json,md}` and +`frontend-geometry-ablation.{json,md}`, plus `geometry-ablation.json`. +Compare baseline/no-skip within the same job, including every raw sample; +three samples do not establish a robust p95. CI success means the scenarios +and measurements worked, not that a timing budget or product geometry contract +passed. Keep the ordinary-layout change as a candidate until these costs have +been reviewed. The separate `--assert-stable` flag remains an explicit geometry +assertion, not a hidden performance threshold. + ## Validating a new workflow before merge GitHub cannot dispatch a new workflow absent from the default branch. For this PR only, temporarily add `push: { branches: [] }` to these workflows, push and inspect both jobs/artifacts. Remove that trigger after verification. Do not merge to obtain a run, add `pull_request_target`, or leave a permanent automatic trigger. Record both the measured SHA and final SHA in the PR when the sole subsequent change removes the validation trigger. diff --git a/scripts/perf/geometry-ablation.mjs b/scripts/perf/geometry-ablation.mjs index 29dddd76e0..beb02fbd17 100644 --- a/scripts/perf/geometry-ablation.mjs +++ b/scripts/perf/geometry-ablation.mjs @@ -44,6 +44,7 @@ if (process.versions.electron) { } else { const { _electron, expect } = await import('@playwright/test'); const { startStaticServer } = await import('../storybook-visual-smoke.mjs'); + const { report, summarize } = await import('./report.mjs'); const server = await startStaticServer('apps/desktop/storybook-static'); let app; const output = path.resolve(process.env.GEOMETRY_OUTPUT ?? 'perf-results/geometry-ablation.json'); @@ -242,6 +243,9 @@ if (process.versions.electron) { trial, initial, readyMs: start, + // CDP duration counters reset on document navigation. + mountLayoutMs: metric(beforeCpu, 'LayoutDuration') * 1000, + mountTaskMs: metric(beforeCpu, 'TaskDuration') * 1000, firstRootMs: state.firstRootMs, heightDrift: Math.max(...heights) - Math.min(...heights), maxReverse, @@ -296,6 +300,37 @@ if (process.versions.electron) { } } } + await report( + 'frontend-geometry-ablation', + { + browser, + repetitions, + viewport: '1200x900', + conditions: + 'One Electron process, rotating modes, fresh DOM. Mount metrics include document navigation and readiness polling; not disk-cold startup or screen presentation.', + limits: + 'Synthetic fixed-range production components, no Host. Three samples per configuration by default; p95 is the maximum. Geometry report is diagnostic, not a default-product correctness gate.', + }, + scenes.flatMap(([scene]) => + modes.flatMap((mode) => { + const group = rows.filter((r) => r.scene === scene && r.mode === mode); + return [ + 'readyMs', + 'mountLayoutMs', + 'mountTaskMs', + 'maxTaskMs', + 'scrollMaxTaskMs', + 'layoutMs', + 'heightDrift', + 'maxReverse', + ].map((metric) => ({ + scenario: `${scene}/${mode}`, + metric, + ...summarize(group.map((r) => r[metric])), + })); + }), + ), + ); console.log(`Report: ${output}`); } finally { await app?.close(); diff --git a/scripts/perf/geometry-navigation.spec.ts b/scripts/perf/geometry-navigation.spec.ts new file mode 100644 index 0000000000..7b5324ea91 --- /dev/null +++ b/scripts/perf/geometry-navigation.spec.ts @@ -0,0 +1,200 @@ +/* + * 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. + */ + +import { test, expect } from '@playwright/test'; +import { withE2eWindow } from '../../apps/desktop/e2e/fixtures'; +import { outputDir, report, summarize } from './report.mjs'; +import path from 'node:path'; + +test('layout ablation: document mount and older history', async () => { + test.setTimeout(180_000); + const samples: Array<{ + mode: string; + action: string; + ms: number; + taskMs: number; + layoutMs: number; + maxLongTaskMs: number; + longTaskCount: number; + }> = []; + await withE2eWindow( + { + seed: false, + readinessSelector: '[data-turn-id]', + e2eFixtureScenario: 'chat-prompt-rail', + locale: 'zh-CN', + showWindow: true, + tracePath: path.join(outputDir, 'geometry-navigation.trace.zip'), + }, + async (page) => { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'reduce' }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + const browser = await cdp.send('Browser.getVersion'); + await page.addInitScript(() => { + const style = document.createElement('style'); + style.textContent = + sessionStorage.getItem('geometry-mode') === 'no-skip' + ? `.maka-transcript-turn, [data-maka-transcript-boundary], .astryx-codeblock [style*="contain-intrinsic-block-size"] { content-visibility:visible !important; contain:layout style paint !important; }` + : ''; + const append = () => document.documentElement.append(style); + if (document.documentElement) append(); + else + new MutationObserver((_, observer) => { + if (document.documentElement) { + append(); + observer.disconnect(); + } + }).observe(document, { childList: true }); + const tasks: Array<{ start: number; duration: number }> = []; + (window as any).__mountTasks = tasks; + new PerformanceObserver((list) => + tasks.push( + ...list.getEntries().map((e) => ({ start: e.startTime, duration: e.duration })), + ), + ).observe({ type: 'longtask', buffered: true }); + }); + const ready = async (turn: number) => { + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${turn}"]`)).toHaveCount(1); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('.maka-markdown-pending')).toHaveCount(0); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + }; + const metric = (m: { metrics: Array<{ name: string; value: number }> }, name: string) => + m.metrics.find((v) => v.name === name)!.value; + for (const mode of ['baseline', 'no-skip', 'no-skip', 'baseline', 'baseline', 'no-skip']) { + await page.evaluate((mode) => sessionStorage.setItem('geometry-mode', mode), mode); + const measure = async (action: string, run: () => Promise) => { + const before = await cdp.send('Performance.getMetrics'); + const from = action === 'mount' ? 0 : await page.evaluate(() => performance.now()); + const start = performance.now(); + await run(); + const ms = performance.now() - start; + const after = await cdp.send('Performance.getMetrics'); + const tasks = await page.evaluate( + (from) => + (window as any).__mountTasks + .filter((t: { start: number }) => t.start >= from) + .map((t: { duration: number }) => t.duration) as number[], + from, + ); + const sample = { + mode, + action, + ms, + // Navigation resets CDP counters; only same-document actions use deltas. + taskMs: + (metric(after, 'TaskDuration') - + (action === 'mount' ? 0 : metric(before, 'TaskDuration'))) * + 1000, + layoutMs: + (metric(after, 'LayoutDuration') - + (action === 'mount' ? 0 : metric(before, 'LayoutDuration'))) * + 1000, + maxLongTaskMs: Math.max(0, ...tasks), + longTaskCount: tasks.length, + }; + expect(sample.taskMs).toBeGreaterThanOrEqual(0); + expect(sample.layoutMs).toBeGreaterThanOrEqual(0); + samples.push(sample); + console.log(JSON.stringify(sample)); + }; + await measure('mount', async () => { + await page.reload(); + await ready(120); + }); + if (mode === 'no-skip') { + expect( + await page + .locator('[data-chat-scroll-container]') + .evaluate( + (root) => + [...root.querySelectorAll('*')].filter( + (el) => getComputedStyle(el).contentVisibility === 'auto', + ).length, + ), + ).toBe(0); + } + await measure('older', async () => { + await page + .locator('.maka-prompt-rail-tick[data-prompt-turn-id="turn-prompt-rail-1"]') + .click(); + await ready(1); + await expect(page.locator('[data-transcript-gap="newer"]')).toBeVisible(); + }); + await measure('latest', async () => { + await page + .getByRole('button', { + name: /^(滚动主对话到底部|Scroll main conversation to bottom)$/, + }) + .click(); + await ready(120); + }); + } + // Prove the observer works without contaminating any measured operation. + const control = await page.evaluate(async () => { + const from = performance.now(); + await new Promise((resolve) => + setTimeout(() => { + const start = performance.now(); + while (performance.now() - start < 100) { + /* observer control */ + } + setTimeout(resolve, 100); + }, 0), + ); + return (window as any).__mountTasks + .filter((t: { start: number }) => t.start >= from) + .map((t: { duration: number }) => t.duration) as number[]; + }); + expect(control.some((duration) => duration >= 90)).toBe(true); + await report( + 'frontend-geometry-navigation', + { + browser, + control, + samples, + viewport: '1400x900', + conditions: + 'One real Desktop + Host, six fresh renderer documents, baseline/no-skip/no-skip/baseline/baseline/no-skip. Existing 120-turn fixture; three measurements per mode/action.', + limits: + 'Mount is renderer reload in a warm application, not disk-cold process startup. DOM readiness includes driver polling, fonts and two rendering frames, not screen presentation. Older/latest uses prompt-rail navigation, not wheel-triggered paging. Trace overhead included; no performance threshold imposed.', + }, + ['baseline', 'no-skip'].flatMap((mode) => + ['mount', 'older', 'latest'].flatMap((action) => { + const group = samples.filter((s) => s.mode === mode && s.action === action); + return (['ms', 'taskMs', 'layoutMs', 'maxLongTaskMs', 'longTaskCount'] as const).map( + (metric) => ({ + scenario: `${action}/${mode}`, + metric, + ...summarize(group.map((s) => s[metric])), + }), + ); + }), + ), + ); + }, + ); +}); diff --git a/scripts/perf/playwright.config.ts b/scripts/perf/playwright.config.ts index 3605e1076f..b489e7b40b 100644 --- a/scripts/perf/playwright.config.ts +++ b/scripts/perf/playwright.config.ts @@ -20,7 +20,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: '.', - testMatch: 'frontend.spec.ts', + testMatch: ['frontend.spec.ts', 'geometry-navigation.spec.ts'], workers: 1, retries: 0, timeout: 180000, From 7f83506fbd24f35a61f606d0f089568c2183ee07 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 17:06:05 +0800 Subject: [PATCH 03/25] fix(ui): keep transcript geometry stable during reader input (#5184) --- .github/workflows/ci.yml | 7 ++ apps/desktop/e2e-budget.json | 4 + .../desktop/e2e/scroll-geometry.spec.ts | 84 ++++++++++++------ .../desktop/src/renderer/app-shell-effects.ts | 20 +++-- apps/desktop/src/renderer/app-shell.tsx | 8 +- .../src/renderer/styles/chat-message.css | 58 ++----------- .../use-app-shell-session-workspace.ts | 17 +++- apps/desktop/stories/app-shell.stories.tsx | 87 +++++-------------- .../transcript-scroll-authority.test.ts | 43 +++++++++ .../ui/src/transcript-scroll-authority.tsx | 75 +++++++++++++--- .../ui/src/transcript-viewport-navigation.ts | 16 +++- packages/ui/src/use-chat-scroll.ts | 18 ++-- patches/@astryxdesign+core+0.5.2.patch | 26 +++++- patches/README.md | 6 ++ scripts/perf/geometry-results.md | 27 +++++- scripts/perf/geometry-window.config.ts | 24 ----- 16 files changed, 315 insertions(+), 205 deletions(-) rename scripts/perf/window-geometry.spec.ts => apps/desktop/e2e/scroll-geometry.spec.ts (68%) delete mode 100644 scripts/perf/geometry-window.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 145b493b53..e8e52aa107 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,6 +443,13 @@ 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_MODE: baseline + 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 diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 3d49a2a75d..7375fb2012 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -69,6 +69,10 @@ "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." }, + "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, "electron": "WorkHub model configuration and attachment sending cross the Host boundary; native floating preserves macOS Dock visibility and pointer dragging verifies model-wheel scrolling and release snapping; a held-open Host Turn verifies immediate prompt visibility, durable transcript handoff and persistence after renderer reload" diff --git a/scripts/perf/window-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts similarity index 68% rename from scripts/perf/window-geometry.spec.ts rename to apps/desktop/e2e/scroll-geometry.spec.ts index acbd6f8cb8..165be933c3 100644 --- a/scripts/perf/window-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -17,17 +17,13 @@ * under the License. */ -// Manual evidence probe on the real Host/window path, deliberately separate -// from fixed-membership geometry. A pass means input/measurement worked, not -// that the reported height/range changes satisfy the future product contract. +// 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 { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { withE2eWindow } from '../../apps/desktop/e2e/fixtures'; +import { withE2eWindow } from './fixtures'; -test('record native thumb drag through transcript window changes', async () => { +test('native thumb keeps its geometry and releases history without moving the reader', async () => { test.setTimeout(180_000); - const rows: unknown[] = []; await withE2eWindow( { seed: false, @@ -40,16 +36,12 @@ test('record native thumb drag through transcript window changes', async () => { await page.setViewportSize({ width: 1000, height: 700 }); const cdp = await page.context().newCDPSession(page); await page.addInitScript(() => { - const mode = sessionStorage.getItem('geometry-mode'); 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; } - ${mode === 'no-skip' ? '.maka-transcript-turn, [data-maka-transcript-boundary], .astryx-codeblock [style*="contain-intrinsic-block-size"]' : ':not(*)'} { - content-visibility:visible !important; contain:layout style paint !important; - }`; + [data-chat-scroll-container]::-webkit-scrollbar-track { background:#ddd; }`; const append = () => document.documentElement.append(style); if (document.documentElement) append(); else @@ -60,8 +52,7 @@ test('record native thumb drag through transcript window changes', async () => { } }).observe(document, { childList: true }); }); - for (const mode of ['baseline', 'no-skip', 'no-skip', 'baseline']) { - await page.evaluate((mode) => sessionStorage.setItem('geometry-mode', mode), mode); + { await page.reload(); await expect(page.locator('[data-turn-id]').first()).toBeVisible(); const returnLatest = page.getByRole('button', { @@ -82,6 +73,8 @@ test('record native thumb drag through transcript window changes', async () => { held: false, done: false, pointerDown: 0, + pointerUp: 0, + readingId: undefined as string | undefined, frames: [] as Array<{ h: number; t: number; @@ -89,10 +82,12 @@ test('record native thumb drag through transcript window changes', async () => { range: string; held: boolean; ms: number; + anchorTop?: number; }>, }; (window as any).__windowGeometry = state; root.addEventListener('pointerdown', () => state.pointerDown++); + document.addEventListener('pointerup', () => state.pointerUp++); const frame = () => { const turns = [...root.querySelectorAll('.maka-transcript-turn')]; state.frames.push({ @@ -102,6 +97,10 @@ test('record native thumb drag through transcript window changes', async () => { 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); }; @@ -142,7 +141,15 @@ test('record native thumb drag through transcript window changes', async () => { await page.waitForTimeout(25); } await page.waitForTimeout(400); - await page.screenshot({ path: path.resolve(`perf-results/window-held-${mode}.png`) }); + const reading = await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]')!; + const top = root.getBoundingClientRect().top; + const turn = [...root.querySelectorAll('[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, @@ -164,21 +171,44 @@ test('record native thumb drag through transcript window changes', async () => { 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(released.length).toBeGreaterThan(2); + 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); - rows.push({ mode, start, heightDrift, heldRanges: ranges.size, ...result }); - await mkdir('perf-results', { recursive: true }); - await writeFile('perf-results/window-geometry.json', JSON.stringify({ rows }, null, 2)); - console.log( - JSON.stringify({ - mode, - heightDrift, - heldRanges: ranges.size, - pointerDown: result.pointerDown, - }), - ); } }, ); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 2d295c6f53..07b9104ee0 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -320,6 +320,7 @@ export function useActiveSessionEvents(options: { setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; setMessageLoadPending: (pending: boolean) => void; setMessages: (messages: StoredMessage[]) => void; + commitTranscriptRange: (sessionId: string, commit: () => void) => void; transcriptRangeRef: RefBox; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: Pick; @@ -338,15 +339,18 @@ export function useActiveSessionEvents(options: { const applyTranscript = useEffectEvent(( sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, + isDisposed: () => boolean, ) => { - if (options.activeIdRef.current === sessionId) { - const snapshot = store.snapshot(); - options.setMessages([...snapshot.messages]); - if (snapshot.ready) { - clearMessageLoadError(sessionId); - options.setMessageLoadPending(false); + options.commitTranscriptRange(sessionId, () => { + if (!isDisposed() && options.activeIdRef.current === sessionId) { + const snapshot = store.snapshot(); + options.setMessages([...snapshot.messages]); + if (snapshot.ready) { + clearMessageLoadError(sessionId); + options.setMessageLoadPending(false); + } } - } + }); }); const applyReadError = useEffectEvent((sessionId: string, error: unknown) => { if (options.activeIdRef.current === sessionId) { @@ -409,7 +413,7 @@ export function useActiveSessionEvents(options: { now: Date.now(), }), })); - const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, transcript)); + const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, transcript, () => disposed)); const openTranscript = (signal: AbortSignal) => window.maka.transcripts.open( activeId, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 84ee566e35..c8d8c63c88 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -325,6 +325,7 @@ function AppShellContent({ retireCancelledTransientMessages, removeTransientMessage, transcriptRangeRef, + publishedTranscriptRange, messageLoadPending, setMessageLoadPending, sessionUiController, @@ -2010,6 +2011,7 @@ function AppShellContent({ activeSession?.profileId, ); useActiveSessionEvents({ + commitTranscriptRange: sessionUiController.transcriptViewportNavigation.commitRange, uiLocale, activeId: activeHostSession?.id, observationAuthorityRevision: observationAuthorityRef.current.revision, @@ -2184,10 +2186,8 @@ function AppShellContent({ const activeUnavailableTranscriptRestore = activeId ? transcriptRestoreUnavailableBySession[activeId] : undefined; - const activeTranscriptRange = Conversation.transcriptReadingPosition.currentRange( - transcriptRangeRef.current, - activeId, - ); + const activeTranscriptRange = publishedTranscriptRange?.sessionId === activeId + ? publishedTranscriptRange : undefined; const homeSurfaceActive = sessionsSelected && messages.length === 0 && diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 42cf038a26..a2f94be20c 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,12 +42,9 @@ width: 100%; } -/* Inserting earlier turns above the reader must not move what they are - reading. The browser's scroll anchoring does exactly that, so state the - dependency on the scroller that runs it rather than inheriting the `auto` - default: Maka reads no geometry and restores no position of its own. The - one case anchoring declines is a scroller sitting at zero, compensated in - useChatScroll after the turns land. */ +/* Native anchoring handles ordinary content growth. The scroll authority + disables it during following and atomic range replacement, when it owns + the position write itself. */ [data-chat-scroll-container='true'] { overflow-anchor: auto; } @@ -66,8 +63,8 @@ width: 100%; flex-direction: column; gap: var(--spacing-4); - content-visibility: auto; - contain-intrinsic-block-size: auto 280px; + /* Resident Turns contribute their real size before the first scroll. */ + contain: layout style paint; } .maka-chat-message-loading { @@ -137,49 +134,10 @@ gap: var(--space-1); } -/* A transcript range is bounded by complete Turns, so one unusually large - Turn can still be much taller than the scrollport. The outer Turn's - content-visibility boundary stops helping as soon as any part of that Turn - becomes relevant. Keep the stable timeline blocks inside it independently - skippable so Chromium does not lay out and paint every Markdown, reasoning, - and tool subtree while the reader crosses one nearby block. - - Renderers apply the marker at the source of each timeline block, including - the children of a Processing fold. `auto` retains the measured block size - after first paint, preserving native scroll - anchoring when a skipped block leaves and re-enters the viewport. */ +/* Keep containment without skipping layout: first visibility must not replace + an estimate and change the scroll range. Window membership bounds residency. */ .maka-chat-message-list [data-maka-transcript-boundary] { - content-visibility: auto; - /* First-paint intrinsic-size ESTIMATE for scroll-anchor stability, not a - fixed height: `auto px` still grows to the block's real size after - paint. 96px is the single-line answer baseline; tall multi-line blocks - override it below. */ - contain-intrinsic-block-size: auto 96px; -} - -/* A long prompt can put even the first answer beyond the relevance margin. - The streaming frontier must contribute its real height before following; - completed blocks in the same active Turn still retain lazy layout. */ -.maka-chat-message-list [data-maka-transcript-boundary][data-live-streaming='true'] { - content-visibility: visible; -} - -/* Container blocks — a Processing sequence, a linked-agent list — hold many - entries, so their first-paint estimate stays multi-line. It remains an - estimate rather than a clamp: the block grows to its measured size after - paint. */ -.maka-chat-message-list [data-maka-transcript-boundary="large"] { - contain-intrinsic-block-size: auto 320px; -} - -/* Collapsed disclosures are the exception among the "large" sites: a folded - reasoning run or tool/activity card renders as one summary row measuring - 24–32px, and a 320px estimate materializes as a ~290px collapse under a - reader scrolling up cold — the anchor-jump the cold-scroll story gates. - Estimate them at their collapsed height; `auto` still remembers the real - expanded size once a reader opens one. */ -.maka-chat-message-list :is(.maka-deep-thinking, .maka-tool-activity-card)[data-maka-transcript-boundary='large'] { - contain-intrinsic-block-size: auto 32px; + contain: layout style paint; } /* An expanded reasoning header stays reachable while its own detail is being diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index b2ba3134cc..7a573c748b 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -30,6 +30,7 @@ import { useAppShellSessionList } from './use-app-shell-session-list.js'; import { createBootstrapSelectionLease } from './bootstrap-selection-lease.js'; import { hasNewTaskReloadIntent } from './new-task-reload-intent.js'; import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js'; +import { currentTranscriptRange } from './features/conversation/controller/transcript-reading-position.js'; import { createSessionWorkspaceActions, type SessionWorkspaceActions, @@ -54,7 +55,13 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { }); const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); - const [messages, setMessages] = useState([]); + // Messages and their gap flags are one published view. Reading flags from + // the receiving store during a held gesture would resize the document even + // when message publication is deferred. + const [transcriptView, setTranscriptView] = useState<{ + messages: StoredMessage[]; + range: ReturnType | undefined; + }>({ messages: [], range: undefined }); const messagesRef = useRef([]); const [transientMessages, setTransientMessages] = useState([]); const transientMessagesBySessionRef = useRef( @@ -79,7 +86,10 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { // controller is created once per renderer, so this identity is fixed and // the once-created factory may capture it. setActiveIdState: catalog.setActiveSessionId, - setMessagesState: setMessages, + setMessagesState: (messages) => setTranscriptView({ + messages, + range: messages.length ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) : undefined, + }), setTransientMessagesState: setTransientMessages, setMessageLoadPending, clearSessionUiState: sessionUiController.clearSessionUiState, @@ -102,7 +112,8 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { activeIdRef, bootstrapSelectionLease: bootstrapSelectionLeaseRef.current, ...actions, - messages, + messages: transcriptView.messages, + publishedTranscriptRange: transcriptView.range, transientMessages, transcriptRangeRef, messageLoadPending, diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 83dbd09c15..e50249abbf 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1115,7 +1115,6 @@ export const WideAssistantProse: Story = { const turnRect = turn.getBoundingClientRect(); expect(turnRect.width).toBeGreaterThan(680); expect(turnRect.right - paragraph.getBoundingClientRect().right).toBeLessThanOrEqual(1); - expect(getComputedStyle(boundary).contentVisibility).toBe('auto'); // Paint containment (`content-visibility: auto`, `contain: paint`, // `overflow` other than visible) clips to the rounded padding box, and // headless Chromium does not reproduce that clip, so pin the geometry: @@ -2368,6 +2367,7 @@ function SettledTranscriptHarness({ */ function HistoryHarness({ turns }: { turns: number }) { const [range, setRange] = useState({ from: 0, count: turns }); + const [viewportNavigation] = useState(createTranscriptViewportNavigation); useEffect(() => { historyLoads.length = 0; }, []); @@ -2375,14 +2375,15 @@ function HistoryHarness({ turns }: { turns: number }) { -HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, onPrefetchHistory: async (edge) => { if (edge !== 'older') return false; historyLoads.push(firstResidentTurnId() ?? '(none)'); - setRange((current) => ({ + viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ from: current.from - HISTORY_BATCH, count: current.count + HISTORY_BATCH, - })); + }))); await painted(2); return true; }, @@ -2648,6 +2649,8 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { render: () => , play: async () => { const root = tailScroller(); + await document.fonts.ready; + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); // A single Turn taller than several viewports is the point; without the // overflow the rest proves nothing. @@ -2655,14 +2658,6 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { root.scrollHeight, JSON.stringify(tailMetrics()), ).toBeGreaterThan(root.clientHeight * 3); - // The containment claim itself, in the same Chromium the app ships: - // offscreen timeline blocks are genuinely skipped, not merely marked. - // (This carries the deleted Electron spec's assertion — #4825 moved this - // tier of coverage below Electron.) - const skipped = [...root.querySelectorAll('[data-maka-transcript-boundary]')] - .filter((element) => !element.checkVisibility({ contentVisibilityAuto: true })) - .length; - expect(skipped, 'no offscreen boundary is skipped').toBeGreaterThan(0); // The visible block nearest the middle of the scrollport, re-chosen each // step so it is always one the reader can actually see. @@ -2684,15 +2679,8 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { return anchor; }; - // Cold: no warmup pass has rendered the blocks above, so each upward step - // materializes first-paint intrinsic-size estimates. The criterion is what - // the reader sees, so it is measured in viewport space: an anchor they were - // reading should move down by exactly the step they asked for. Native - // `overflow-anchor` compensates the materialization by adjusting - // `scrollTop`, so neither document-space growth nor the scrollTop delta may - // be the yardstick — comparing against either reports the (allowed) - // correction itself as a jump. Only `|viewport move − intended step|` is a - // jump the reader experiences. + // First traversal, without a preparatory scroll. The visible block must + // move by the requested distance, without an extra layout correction. let worstUnexpected = 0; const steps: Array> = []; for (let step = 0; step < 8 && root.scrollTop > 0; step += 1) { @@ -2716,19 +2704,12 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { grewBy: root.scrollHeight - heightBefore, }); } - // On main this story reads 0 by construction — no sub-turn boundary exists - // to materialize. On this branch the error tracks materialization exactly: - // a zero-growth step read 0px, and with the folded-disclosure estimate at - // 320px against a 24–32px collapsed row, steps measured up to 244px — a - // reader-visible stall of a 240px scroll step. With the collapsed estimate - // corrected, the residual is the answer blocks' estimate error, which stays - // well under half a step. The bound is half a step: loose enough for - // per-run variance, tight enough that a stalled or reversed step can never - // pass again. + // Fixed content must move only by the requested distance, including on + // the first traversal. One CSS pixel allows rounding, not an estimate. expect( worstUnexpected, `worst unexpected reading-anchor move: ${Math.round(worstUnexpected)}px; steps: ${JSON.stringify(steps)}`, - ).toBeLessThanOrEqual(120); + ).toBeLessThanOrEqual(1); }, }; @@ -2796,26 +2777,11 @@ export const EarlierHistoryLandsAboveTheReader: Story = { }; /** - * The reader going *up* through Turns that have never rendered. - * - * A bound, not stillness. A Turn off screen is laid out at - * `contain-intrinsic-block-size: auto 280px` and swaps to its real height on - * the way past, so travelling through them moves things by construction — - * about 8% of the transcript here. What the bound says is that one Turn owes - * at most one estimate, keeping the correction proportional to Turns crossed - * rather than to what is inside them. + * First upward traversal of a deep fixed transcript: document height and + * the reader's content position must remain stable without a warm-up pass. */ const TRAVERSAL_STEP = 700; -/** What one Turn is worth, measured after everything has rendered once. */ -function medianTurnHeight(): number { - const heights = [...tailScroller().querySelectorAll('[data-turn-id]')] - .map((turn) => turn.getBoundingClientRect().height) - .sort((a, b) => a - b); - if (heights.length === 0) throw new Error('the transcript has no mounted turn'); - return heights[Math.floor(heights.length / 2)]; -} - /** The first Turn whose box is still on screen, and where it starts. */ function anchorInView(): { turnId: string; top: number } { const root = tailScroller(); @@ -2831,6 +2797,8 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { render: () => , play: async () => { const root = tailScroller(); + await document.fonts.ready; + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); const heightBefore = root.scrollHeight; expect( @@ -2856,20 +2824,15 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { expect(steps, 'the traversal has to have taken real steps').toBeGreaterThan(6); const worstDrift = Math.max(...drifts.map(Math.abs)); - const turnHeight = medianTurnHeight(); - // No single step throws the reader past a whole exchange. One Turn's worth - // of correction is the most one Turn can owe. - expect(worstDrift, `per-step drift: ${drifts.join(' ')} against a Turn of ${turnHeight}`) - .toBeLessThanOrEqual(turnHeight); - - // And over the whole traversal the corrections stay proportional to the - // Turns crossed. Measured at ~8% here; #4259's 63% is the failure this - // exists to catch. + expect(worstDrift, `per-step drift: ${drifts.join(' ')}`) + .toBeLessThanOrEqual(1); + + // The fixed document keeps its full height throughout the traversal. const heightAfter = root.scrollHeight; expect( - Math.abs(heightAfter - heightBefore) / heightBefore, - JSON.stringify({ heightBefore, heightAfter, steps, turnHeight }), - ).toBeLessThanOrEqual(0.15); + Math.abs(heightAfter - heightBefore), + JSON.stringify({ heightBefore, heightAfter, steps }), + ).toBeLessThanOrEqual(1); // And the reader can still get back. dockButton().click(); @@ -2899,15 +2862,13 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { // The one position where the browser declines to anchor, and the one the // wheel-to-load path puts the reader in. scrollAsReader(root, 0); + const reading = anchorInView(); wheelUp(root); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); await painted(6); - // Anchoring resumes at an offset of one pixel, so the offset itself is the - // evidence: left at zero the browser holds the scroller at the top and - // every turn that arrives pushes the reader's content down the viewport. - expect(tailScroller().scrollTop).toBeGreaterThanOrEqual(1); + expect(Math.abs(turnTop(reading.turnId) - reading.top)).toBeLessThanOrEqual(1); }, }; diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 7170af5307..0d577e092a 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -183,6 +183,49 @@ test('Ctrl and Meta wheel zoom preserve following without requesting history', ( }); }); +test('a held scrollbar coalesces range publication until release, including a stationary hold', () => { + withObservers((_resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + const commits: number[] = []; + root.grabScrollbar(); + root.scrollTop -= 100; + root.emitScroll(); + authority.commitWhenIdle(() => commits.push(1)); + authority.commitWhenIdle(() => commits.push(2)); + root.end(); frame(); frame(); + assert.deepEqual(commits, []); + root.ownerDocument.dispatchEvent(new Event('pointerup')); + frame(); frame(); + assert.deepEqual(commits, [2]); + }); +}); + +test('an edge wheel without scrollend publishes after input settles and detach drops pending work', () => { + withObservers((_resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + root.scrollTop = 0; + let commits = 0; + const phases: string[] = []; + authority.subscribeToReaderScroll((phase) => { + phases.push(phase); + if (phase === 'input') authority.commitWhenIdle(() => commits++); + }); + root.input(-100); + assert.equal(commits, 0); + frame(); frame(); + assert.equal(commits, 1); + assert.deepEqual(phases, ['input', 'settled']); + root.grabScrollbar(); + authority.commitWhenIdle(() => commits++); + detach(); frame(); frame(); + assert.equal(commits, 1); + }); +}); + test('content that grows under a pinned transcript keeps the tail on screen', () => { withObservers((resize) => { const root = fakeRoot(); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index d02c51803e..eec42ec7e1 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -22,7 +22,7 @@ * host; explicit navigation releases this authority before moving the viewport. * * pinned → content that grows writes `scrollTop = scrollHeight` - * !pinned → nothing here writes `scrollTop`, ever + * !pinned → only an explicit range publication restores its reading anchor * * While pinned, disable native anchoring so content cannot move the viewport * behind this authority's own write. Once released, restore native anchoring @@ -41,6 +41,7 @@ import { type ReactNode, } from 'react'; import { ChatLayoutScrollButton } from '@astryxdesign/core/Chat'; +import { flushSync } from 'react-dom'; /** Astryx's own thresholds, so the affordance keeps the feel readers learnt. */ const PIN_THRESHOLD_PX = 10; @@ -60,22 +61,24 @@ export interface TranscriptScrollSnapshot { } export interface TranscriptScrollAuthority { + /** Publish the newest resident range after the current input operation ends. */ + commitWhenIdle(commit: () => void): void; /** Take the scroller. Returns the detach for the effect that called it. */ attach(root: HTMLElement | null): () => void; /** One-shot: put the tail back under the reader and follow it again. */ pinToTail(): void; /** * The reader chose a position, so stop following. A command that moves the - * viewport itself calls this first; afterwards nothing here writes, which is - * why a command cannot race the policy. + * viewport itself calls this first; afterwards automatic following is off. */ releasePin(): void; /** * Input can request history at an edge before any movement. Scroll reports - * the resulting reading position. Neither phase is emitted for layout alone; + * the resulting reading position; settled rechecks the final edge after a + * gesture. No phase is emitted for layout alone; * consumers do not interpret raw wheel or scroll events themselves. */ - subscribeToReaderScroll(listener: (phase: 'input' | 'scroll') => void): () => void; + subscribeToReaderScroll(listener: (phase: 'input' | 'scroll' | 'settled') => void): () => void; /** * The reading position, measured now and published like any other move. For * a caller that has just moved the viewport or the content itself and cannot @@ -107,10 +110,39 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // Geometry belongs to a known input operation, never the other way around. // scrollend also covers smooth keyboard scrolling and touchpad inertia. let gesture: { top: number; direction?: 'up' | 'down' } | undefined; + let pendingCommit: (() => void) | undefined; + let pointer: number | undefined; + let touchHeld = false; + const commitRange = (commit: () => void): void => { + const target = root; + if (!target || pinned) { commit(); return; } + const top = target.getBoundingClientRect().top; + const anchor = [...target.querySelectorAll('[data-turn-id]')] + .find((turn) => turn.getBoundingClientRect().bottom > top); + if (!anchor) { commit(); return; } + const before = anchor.getBoundingClientRect().top; + // A gap notice is a poor native anchor: it survives a range replacement + // while the paragraph beneath it moves. Restore a content Turn once, with + // native compensation disabled for the same synchronous publication. + target.style.overflowAnchor = 'none'; + try { + flushSync(commit); + const next = target.querySelector(`[data-turn-id="${CSS.escape(anchor.dataset.turnId!)}"]`); + if (next) target.scrollTop += next.getBoundingClientRect().top - before; + } finally { + target.style.overflowAnchor = pinned ? 'none' : 'auto'; + } + }; + const flushCommit = (): void => { + if (gesture || pointer !== undefined || touchHeld) return; + const commit = pendingCommit; + pendingCommit = undefined; + if (commit) commitRange(commit); + }; let readingTurnId: string | undefined; let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail, readingTurnId }; const listeners = new Set<() => void>(); - const readerListeners = new Set<(phase: 'input' | 'scroll') => void>(); + const readerListeners = new Set<(phase: 'input' | 'scroll' | 'settled') => void>(); const distanceToTail = (): number => root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; const readTurn = (): string | undefined => { @@ -136,11 +168,15 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { awayFromTail = false; publish(); }; - const reportReader = (phase: 'input' | 'scroll'): void => { + const reportReader = (phase: 'input' | 'scroll' | 'settled'): void => { for (const listener of [...readerListeners]) listener(phase); }; return { + commitWhenIdle(commit) { + pendingCommit = commit; + flushCommit(); + }, attach(next) { root = next; const target = root; @@ -150,13 +186,14 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const begin = (event: Event, direction: 'up' | 'down'): void => { if (event.defaultPrevented || !reachesTranscript(event, target, direction)) return; const remaining = direction === 'up' ? target.scrollTop : distanceToTail(); + gesture = { top: gesture?.top ?? target.scrollTop, direction }; if (remaining <= 0) { // An edge gesture can ask for an adjacent history page even though // it produces no scroll (and therefore no scrollend). reportReader('input'); + onScrollEnd(); return; } - gesture = { top: gesture?.top ?? target.scrollTop, direction }; pinned = false; publish(); reportReader('input'); @@ -176,7 +213,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { : ['ArrowDown', 'PageDown', 'End', ' '].includes(event.key) ? 'down' : undefined; if (direction) begin(event, direction); }; - let pointer: number | undefined; const onPointerDown = (event: PointerEvent): void => { if (event.defaultPrevented || event.button !== 0 || event.pointerType === 'touch' || event.target !== target) return; @@ -188,6 +224,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { }; const onPointerUp = (): void => { pointer = undefined; + onScrollEnd(); const pending = gesture; if (!pending || pending.direction !== undefined) return; // Native track clicks can start their smooth scroll after pointerup. @@ -196,11 +233,13 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { requestAnimationFrame(() => { if (gesture !== pending || pending.direction !== undefined) return; gesture = undefined; + flushCommit(); if (pinned) writeToTail(); }); }; let touchY: number | undefined; const onTouchStart = (event: TouchEvent): void => { + touchHeld = true; touchY = event.touches.length === 1 ? event.touches[0]!.clientY : undefined; }; const onTouchMove = (event: TouchEvent): void => { @@ -210,7 +249,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { } touchY = nextY; }; - const onTouchEnd = (): void => { touchY = undefined; }; + const onTouchEnd = (): void => { touchY = undefined; touchHeld = false; onScrollEnd(); flushCommit(); }; const onScroll = (): void => { awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; readingTurnId = readTurn(); @@ -246,11 +285,16 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // step report any continuation before retiring its input provenance. // This schedules no scroll and uses no time-based ignore window. requestAnimationFrame(() => requestAnimationFrame(() => { - if (gesture !== ended || ended.top !== top) return; + if (gesture !== ended || ended.top !== top || pointer !== undefined || touchHeld) return; pinned = ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX; gesture = undefined; + flushCommit(); publish(); if (pinned) writeToTail(); + // An anchor navigation can supersede the last in-flight page while + // the gesture is held. Recheck its edge once after publication; + // waiting for another movement would strand a reader at scrollTop 0. + if (ended.direction) reportReader('settled'); })); }; target.addEventListener('wheel', onWheel, { passive: true }); @@ -311,18 +355,27 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { target.removeEventListener('scrollend', onScrollEnd); target.style.overflowAnchor = previousOverflowAnchor; gesture = undefined; + pointer = undefined; + touchHeld = false; + pendingCommit = undefined; if (root === target) root = null; }; }, pinToTail() { gesture = undefined; + pointer = undefined; + touchHeld = false; pinned = true; + flushCommit(); writeToTail(); publish(); }, releasePin() { gesture = undefined; pinned = false; + // Commands can originate in a React effect. Publish before their next + // positioning frame, outside React's lifecycle, if a range is pending. + if (pendingCommit) requestAnimationFrame(flushCommit); awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); }, diff --git a/packages/ui/src/transcript-viewport-navigation.ts b/packages/ui/src/transcript-viewport-navigation.ts index d25513cdaf..9dfa8b8883 100644 --- a/packages/ui/src/transcript-viewport-navigation.ts +++ b/packages/ui/src/transcript-viewport-navigation.ts @@ -17,10 +17,24 @@ * under the License. */ -/** Explicit viewport commands are consumed once, never replayed on mount or growth. */ +/** Bridge the active surface's scroll authority to conversation commands and publication. + * No geometry or pending range state lives here; detaching invalidates queued callbacks. */ export function createTranscriptViewportNavigation() { const listeners = new Set<(sessionId: string) => void>(); + let commitScheduler: { sessionId: string; schedule: (commit: () => void) => void } | undefined; return { + attachCommitScheduler(sessionId: string, schedule: (commit: () => void) => void): () => void { + const attached = { sessionId, schedule }; + commitScheduler = attached; + return () => { if (commitScheduler === attached) commitScheduler = undefined; }; + }, + commitRange(sessionId: string, commit: () => void): void { + const attached = commitScheduler; + if (attached?.sessionId === sessionId) attached.schedule(() => { + if (commitScheduler === attached) commit(); + }); + else commit(); + }, followLatest(sessionId: string): void { for (const listener of [...listeners]) listener(sessionId); }, diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 5359479af1..6fc0d109e3 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -22,14 +22,13 @@ * authority that owns it (`transcript-scroll-authority.ts`). * * A command is one-shot — jump to a turn the reader picked, ask for the history - * above them — and it releases the pin first, because the authority writes - * nothing while the pin is released and so a command can never be fighting a - * policy. That was the shape every previous round of this code had. + * above them — and it releases the pin first, so explicit navigation does not + * fight following. The authority also owns range publication and its one-shot + * reading-anchor restoration. * * What decides whether the reader wants either thing is never re-derived here. * "They have left the tail" is the pin, and the pin has one owner. Nothing here - * compensates for content that lands above them either; `overflow-anchor: auto` - * does that continuously, and for free. + * compensates for content that lands above them; that belongs to the authority. */ import { useEffect, useRef, useState, type RefObject } from 'react'; @@ -109,6 +108,10 @@ export function useChatScroll(input: { // which lands after passive effects, so this is still installed in time. useEffect(() => authority.attach(input.scrollRef.current), [authority, input.scrollRef]); + useEffect(() => input.sessionId + ? input.viewportNavigation?.attachCommitScheduler(input.sessionId, authority.commitWhenIdle) + : undefined, [authority, input.sessionId, input.viewportNavigation]); + // A new conversation either resumes a semantic reading position or arrives // at its tail. Releasing before an async fill is essential: an empty // transcript clamps every pixel offset to zero, but it cannot erase a Turn @@ -181,11 +184,6 @@ export function useChatScroll(input: { : input.hasNewerHistory === true && canPrefetch; const requestHistory = (direction: 'up' | 'down'): void => { if (inFlight[direction]) return; - // The browser anchors the reader against everything that lands above - // them, with one exception: it declines while the scroller sits at zero. - if (direction === 'up' && !authority.getSnapshot().pinned && root.scrollTop < 1) { - root.scrollTop = 1; - } inFlight[direction] = true; void Promise.resolve(prefetchRef.current?.(direction === 'up' ? 'older' : 'newer')) .then( diff --git a/patches/@astryxdesign+core+0.5.2.patch b/patches/@astryxdesign+core+0.5.2.patch index 51d956e14d..cea4da0d6c 100644 --- a/patches/@astryxdesign+core+0.5.2.patch +++ b/patches/@astryxdesign+core+0.5.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js -index 5046603..ec36a00 100644 +index 5046603..f34333e 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js @@ -22,7 +22,7 @@ @@ -41,7 +41,7 @@ index ff34874..0f5ae14 100644 export declare function ChatLayout({ children, composer, density, emptyState, scrollButton, scrollRef: externalScrollRef, xstyle, className, style, 'data-testid': testId, ref, ...rest }: ChatLayoutProps): import("react").JSX.Element; export declare namespace ChatLayout { diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js -index ff9b9fa..d20288e 100644 +index ff9b9fa..f681cd0 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js @@ -85,7 +85,8 @@ const styles = { @@ -218,6 +218,28 @@ index eac4c4b..a21c0c4 100644 return def; } function buildLanguageUncached(lang) { + +diff --git a/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js b/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js +index 4765e55..a93b859 100644 +--- a/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js ++++ b/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js +@@ -218,15 +218,8 @@ function renderLines(lines, highlightSet, renderLineContent, lineNumbers, chunkS + for (let start = 0; start < lines.length; start += chunkSize) { + const end = Math.min(start + chunkSize, lines.length); + const chunkLines = lines.slice(start, end); +- const estimatedHeight = `${chunkLines.length}lh`; + chunks.push(/*#__PURE__*/_jsx("div", { +- ...mergeProps({ +- className: "xb5mbof" +- }, { +- style: { +- containIntrinsicBlockSize: `auto ${estimatedHeight}` +- } +- }), ++ style: { contain: "layout style paint" }, + children: /*#__PURE__*/_jsx(CodeChunk, { + lines: chunkLines, + startIndex: start, diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts index 82ae62c..a97eff5 100644 --- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts diff --git a/patches/README.md b/patches/README.md index 49de7d4cf6..20ee6fadaf 100644 --- a/patches/README.md +++ b/patches/README.md @@ -148,6 +148,12 @@ keep reusing compiled regexes. A call-site language filter would duplicate the dependency's language list, discard the displayed label, and miss the shared CodeEditor path. Delete this hunk when upstream stops caching unsupported labels. +`CodeBlock` retains memoized line chunks, but lays them out without guessed +intrinsic heights. Replacing those estimates on first visibility changed the +transcript scroll range (#5184). Keep layout/style/paint containment. Remove +this hunk when upstream offers equivalent stable geometry; the default-mode +geometry CI covers 1200 lines without any ablation override. + `ChatComposerInput` synchronizes external controlled values into its editable DOM in a layout effect. A passive effect can leave the old multiline draft visible for a frame after the sent message is rendered; clearing it later diff --git a/scripts/perf/geometry-results.md b/scripts/perf/geometry-results.md index 4e96d97d6b..b200212903 100644 --- a/scripts/perf/geometry-results.md +++ b/scripts/perf/geometry-results.md @@ -1,5 +1,25 @@ # #5184 geometry ablation — 2026-09-11 +## Implementation after the experiment + +The measurements below describe the pre-change source, not current product +behavior. The implementation now uses real layout for resident Turns, timeline +blocks and code chunks. The existing scroll authority defers range publication +during input, coalesces pending publication, and restores the reading Turn once +at commit. Messages and gap metadata form one published view; otherwise gap +changes alone moved held height by 68px in the native probe. The old 1px input +nudge and the render-skipping assertion were removed. + +Ordinary CI runs the fixed-range driver with `GEOMETRY_MODE=baseline` and +`--assert-stable`. The former native diagnostic is now +`apps/desktop/e2e/scroll-geometry.spec.ts`: held height/range, monotonic upward +movement, release-frame anchor position, and subsequent navigation are strict +assertions. This implementation targets main, not the unmerged #5170 branch. +The post-change performance run must be compared using the report commit and +environment; a successful job is not a statistical non-inferiority result. + +## Pre-change experiment + This is a diagnostic result, not a production fix or a claim of a universal performance bound. Production CSS and window policy were not modified. @@ -124,9 +144,12 @@ directory): ```sh npm run build:with-deps -npx playwright test --config ../../scripts/perf/geometry-window.config.ts +npx playwright test --config e2e/playwright.config.ts e2e/scroll-geometry.spec.ts ``` -The window report is `apps/desktop/perf-results/window-geometry.json`. Fixed +The original diagnostic and its JSON output belong to measurement commit +`817a5737d`; it has now been replaced by the ordinary E2E regression above. +The regression asserts held range/height, upward monotonicity, every sampled +release-frame anchor position and progress after release. Fixed range reports default to repository `perf-results/geometry-ablation.json` and can be directed with `GEOMETRY_OUTPUT`. Preserve reports before a subsequent run. diff --git a/scripts/perf/geometry-window.config.ts b/scripts/perf/geometry-window.config.ts deleted file mode 100644 index 36c0cce964..0000000000 --- a/scripts/perf/geometry-window.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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. - */ -import { defineConfig } from '@playwright/test'; -import base from './playwright.config'; - -// Explicit diagnostic lane: do not silently add a native-input experiment to -// the established frontend benchmark invocation. -export default defineConfig({ ...base, testMatch: 'window-geometry.spec.ts' }); From 438c4803e80576ef1e193d9837bf2e68503e6509 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 17:11:09 +0800 Subject: [PATCH 04/25] fix(ci): normalize geometry probe ASF headers --- scripts/perf/geometry-navigation.spec.ts | 8 ++++---- scripts/perf/geometry-results.md | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/perf/geometry-navigation.spec.ts b/scripts/perf/geometry-navigation.spec.ts index 7b5324ea91..96d2a132c3 100644 --- a/scripts/perf/geometry-navigation.spec.ts +++ b/scripts/perf/geometry-navigation.spec.ts @@ -1,18 +1,18 @@ /* * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file + * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file + * 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 + * 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 + * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ diff --git a/scripts/perf/geometry-results.md b/scripts/perf/geometry-results.md index b200212903..d9ae2e9145 100644 --- a/scripts/perf/geometry-results.md +++ b/scripts/perf/geometry-results.md @@ -1,3 +1,22 @@ + + # #5184 geometry ablation — 2026-09-11 ## Implementation after the experiment From a1a6d578c2663eb6ab766112ed069070c003bc1e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 17:22:59 +0800 Subject: [PATCH 05/25] fix(ui): move transcript publication into conversation ownership --- apps/desktop/renderer-architecture.json | 14 ++-- .../desktop/src/renderer/app-shell-effects.ts | 17 ++--- apps/desktop/src/renderer/app-shell.tsx | 4 +- .../use-app-shell-session-ui-state.ts | 73 +++++++++++++++++++ .../renderer/features/conversation/index.ts | 1 + .../conversation/model/session-ui-state.ts | 18 ----- .../use-app-shell-session-workspace.ts | 39 ++++------ 7 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8918f334e9..dd7fbe8dea 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -496,7 +496,7 @@ "react": 1 }, "importSpecifiers": 19, - "nonTriviaTokens": 3736 + "nonTriviaTokens": 3740 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 7, @@ -930,23 +930,23 @@ "nonTriviaTokens": 135 }, "src/renderer/use-app-shell-session-workspace.ts": { - "importDeclarations": 8, + "importDeclarations": 7, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { "useAppShellSessionList": 1, "useAppShellSessionUiState": 1, "useExternalStoreSelector": 1, - "useRef": 7, + "useRef": 5, "useSessionCatalogController": 1, - "useState": 3 + "useState": 2 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./app-shell-session-ui-state.js": 1, "./bootstrap-selection-lease.js": 1, + "./features/conversation/index.js": 1, "./new-task-reload-intent.js": 1, "./session-catalog-state.js": 1, "./session-workspace-actions.js": 1, @@ -954,8 +954,8 @@ "./use-external-store-selector.js": 1, "react": 1 }, - "importSpecifiers": 10, - "nonTriviaTokens": 466 + "importSpecifiers": 9, + "nonTriviaTokens": 465 } }, "closure": { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 07b9104ee0..1e52689450 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -20,7 +20,7 @@ import { useEffect, useEffectEvent, useLayoutEffect } from 'react'; import { useHotkeys } from '@astryxdesign/core/hooks'; import type { ConnectionEvent } from '@maka/core/connections'; -import type { SessionChangedEvent, SessionSummary, StoredMessage } from '@maka/core/session'; +import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import type { ThemePalette, ThemePreference } from '@maka/core/settings'; @@ -319,8 +319,7 @@ export function useActiveSessionEvents(options: { completeObservationSeed: (sessionId: string) => void; setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; setMessageLoadPending: (pending: boolean) => void; - setMessages: (messages: StoredMessage[]) => void; - commitTranscriptRange: (sessionId: string, commit: () => void) => void; + publishTranscript: (sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, isDisposed: () => boolean, onReady: () => void) => void; transcriptRangeRef: RefBox; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: Pick; @@ -341,15 +340,9 @@ export function useActiveSessionEvents(options: { store: desktopTranscript.DesktopTranscriptRangeStore, isDisposed: () => boolean, ) => { - options.commitTranscriptRange(sessionId, () => { - if (!isDisposed() && options.activeIdRef.current === sessionId) { - const snapshot = store.snapshot(); - options.setMessages([...snapshot.messages]); - if (snapshot.ready) { - clearMessageLoadError(sessionId); - options.setMessageLoadPending(false); - } - } + options.publishTranscript(sessionId, store, isDisposed, () => { + clearMessageLoadError(sessionId); + options.setMessageLoadPending(false); }); }); const applyReadError = useEffectEvent((sessionId: string, error: unknown) => { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c8d8c63c88..ab45d5cbee 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -326,6 +326,7 @@ function AppShellContent({ removeTransientMessage, transcriptRangeRef, publishedTranscriptRange, + publishTranscript, messageLoadPending, setMessageLoadPending, sessionUiController, @@ -2011,7 +2012,7 @@ function AppShellContent({ activeSession?.profileId, ); useActiveSessionEvents({ - commitTranscriptRange: sessionUiController.transcriptViewportNavigation.commitRange, + publishTranscript, uiLocale, activeId: activeHostSession?.id, observationAuthorityRevision: observationAuthorityRef.current.revision, @@ -2022,7 +2023,6 @@ function AppShellContent({ completeObservationSeed, setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, setMessageLoadPending, - setMessages, transcriptRangeRef, setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts new file mode 100644 index 0000000000..73816f47f8 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +import { useRef, useState } from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import { currentTranscriptRange } from './transcript-reading-position.js'; +import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; + +interface TranscriptSource { + range(): { readonly sessionId: string; readonly hasOlder: boolean; readonly hasNewer: boolean }; + snapshot(): { readonly messages: readonly StoredMessage[]; readonly ready: boolean }; +} + +/** The rendered messages and gap flags are a single publication. The source + * may advance during reader input, but only the scroll authority admits it. */ +export function useAppShellSessionUiState( + activeIdRef: { current: string | undefined }, + publishMessages: (messages: StoredMessage[]) => void, +) { + // The observable controller retains its own identity and subscriptions; + // publication is the React view of the active transcript, not a store copy. + const controllerRef = useRef(null); + controllerRef.current ??= createAppShellSessionUiStateController(); + const controller = controllerRef.current; + const transcriptRangeRef = useRef(undefined); + const messagesRef = useRef([]); + const [view, setView] = useState<{ + messages: StoredMessage[]; + range: ReturnType | undefined; + }>({ messages: [], range: undefined }); + + return { + controller, + publication: { + transcriptRangeRef, + messagesRef, + messages: view.messages, + publishedTranscriptRange: view.range, + setMessagesState(messages: StoredMessage[]) { + setView({ + messages, + range: messages.length + ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) + : undefined, + }); + }, + publishTranscript(sessionId: string, store: TranscriptSource, isDisposed: () => boolean, onReady: () => void) { + controller.transcriptViewportNavigation.commitRange(sessionId, () => { + if (isDisposed() || activeIdRef.current !== sessionId) return; + const snapshot = store.snapshot(); + publishMessages([...snapshot.messages]); + if (snapshot.ready) onReady(); + }); + }, + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 2a7e187bec..36445fb87b 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -45,6 +45,7 @@ export type { ConversationServices } from './ports.js'; export { ConversationServicesProvider } from './services.js'; export { SessionLocalMessages } from './controller/session-local-messages.js'; export { createConversationDisplayFrameScheduler } from './controller/display-frame-scheduler.js'; +export { useAppShellSessionUiState } from './controller/use-app-shell-session-ui-state.js'; export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; export { type PendingAttachment, toComposerIngestItems, retainedAttachmentRefs } from '@maka/ui/composer-attachments'; diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts index d3471401e6..691407f16f 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -17,7 +17,6 @@ * under the License. */ -import { useRef } from 'react'; import type { MessageQueueEntryProjection, ShellRunUpdate } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import { createTranscriptViewportNavigation, type InteractionQueues, type LiveTurnBuffer } from '@maka/ui'; @@ -224,23 +223,6 @@ export function createAppShellSessionUiStateController( export type AppShellSessionUiStateController = ReturnType; -/** - * Owns the controller for the component's lifetime. Deliberately does NOT - * subscribe: readers select what they need through - * `useExternalStoreSelector`, so no single component re-renders for every - * write to the store (#1985). - * - * Returns the controller itself rather than a bag of its members. The bag had - * to name every setter, so did the workspace hook above it, and so did - * AppShell's destructure — three places to edit for one new map, and three - * chances for them to disagree about what the store offers. - */ -export function useAppShellSessionUiState(): AppShellSessionUiStateController { - const controllerRef = useRef(null); - controllerRef.current ??= createAppShellSessionUiStateController(); - return controllerRef.current; -} - function createRuntimeSessionRegistry() { const ref: { current: Record } = { current: {} }; return { diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index 7a573c748b..c7799f8790 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -18,9 +18,8 @@ */ import { useRef, useState } from 'react'; -import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; -import { useAppShellSessionUiState } from './app-shell-session-ui-state.js'; +import * as Conversation from './features/conversation/index.js'; import { selectActiveSessionId, useSessionCatalogController, @@ -30,7 +29,6 @@ import { useAppShellSessionList } from './use-app-shell-session-list.js'; import { createBootstrapSelectionLease } from './bootstrap-selection-lease.js'; import { hasNewTaskReloadIntent } from './new-task-reload-intent.js'; import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js'; -import { currentTranscriptRange } from './features/conversation/controller/transcript-reading-position.js'; import { createSessionWorkspaceActions, type SessionWorkspaceActions, @@ -49,32 +47,27 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const catalog = useSessionCatalogController(); const activeId = useExternalStoreSelector(catalog, selectActiveSessionId); const activeIdRef = useRef(undefined); - const sessionUiController = useAppShellSessionUiState(); + const actionsRef = useRef(null); + const { controller: sessionUiController, publication } = Conversation.useAppShellSessionUiState( + activeIdRef, + (messages) => actionsRef.current!.setMessages(messages), + ); const sessionList = useAppShellSessionList(toastApi, { catalog, }); const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); - // Messages and their gap flags are one published view. Reading flags from - // the receiving store during a held gesture would resize the document even - // when message publication is deferred. - const [transcriptView, setTranscriptView] = useState<{ - messages: StoredMessage[]; - range: ReturnType | undefined; - }>({ messages: [], range: undefined }); - const messagesRef = useRef([]); + const { messagesRef, transcriptRangeRef, setMessagesState } = publication; const [transientMessages, setTransientMessages] = useState([]); const transientMessagesBySessionRef = useRef( new Map>(), ); - const transcriptRangeRef = useRef(undefined); const [messageLoadPending, setMessageLoadPending] = useState(false); - const actionsRef = useRef(null); - // Every dep below is a ref box, a state setter, or a method of the - // once-created session-UI controller, so one instance serves the renderer's - // lifetime. Consumers list these in dep arrays and pass them as props; a - // per-render identity there is what defeated the Session rail's memo. + // The captured publication setter only reads stable refs and writes React + // state. Along with the controller methods and other refs, it lets one + // actions instance serve the renderer's lifetime without defeating the + // Session rail's memo with new action identities on every render. actionsRef.current ??= createSessionWorkspaceActions({ activeIdRef, messagesRef, @@ -86,10 +79,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { // controller is created once per renderer, so this identity is fixed and // the once-created factory may capture it. setActiveIdState: catalog.setActiveSessionId, - setMessagesState: (messages) => setTranscriptView({ - messages, - range: messages.length ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) : undefined, - }), + setMessagesState, setTransientMessagesState: setTransientMessages, setMessageLoadPending, clearSessionUiState: sessionUiController.clearSessionUiState, @@ -112,8 +102,9 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { activeIdRef, bootstrapSelectionLease: bootstrapSelectionLeaseRef.current, ...actions, - messages: transcriptView.messages, - publishedTranscriptRange: transcriptView.range, + messages: publication.messages, + publishedTranscriptRange: publication.publishedTranscriptRange, + publishTranscript: publication.publishTranscript, transientMessages, transcriptRangeRef, messageLoadPending, From 564f7e532e4efc7ff22e3035dd0ab780d2f5f2a7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 17:33:18 +0800 Subject: [PATCH 06/25] fix(ui): preserve transcript publication action identity --- .../use-app-shell-session-ui-state.ts | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index 73816f47f8..08fda315a7 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -45,6 +45,28 @@ export function useAppShellSessionUiState | undefined; }>({ messages: [], range: undefined }); + // These actions capture only lifetime-stable refs, setters and the workspace + // callback that dispatches through its actions ref. Keep their identities as + // stable as the other workspace actions consumers receive. + const [actions] = useState(() => ({ + setMessagesState(messages: StoredMessage[]) { + setView({ + messages, + range: messages.length + ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) + : undefined, + }); + }, + publishTranscript(sessionId: string, store: TranscriptSource, isDisposed: () => boolean, onReady: () => void) { + controller.transcriptViewportNavigation.commitRange(sessionId, () => { + if (isDisposed() || activeIdRef.current !== sessionId) return; + const snapshot = store.snapshot(); + publishMessages([...snapshot.messages]); + if (snapshot.ready) onReady(); + }); + }, + })); + return { controller, publication: { @@ -52,22 +74,7 @@ export function useAppShellSessionUiState boolean, onReady: () => void) { - controller.transcriptViewportNavigation.commitRange(sessionId, () => { - if (isDisposed() || activeIdRef.current !== sessionId) return; - const snapshot = store.snapshot(); - publishMessages([...snapshot.messages]); - if (snapshot.ready) onReady(); - }); - }, + ...actions, }, }; } From 92e39b0ca19b6f9956b647dab940484d0857a501 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 18:03:32 +0800 Subject: [PATCH 07/25] test(ui): measure history insertion after initial markdown layout --- apps/desktop/stories/app-shell.stories.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index e50249abbf..3a6a3d1321 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2850,6 +2850,10 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { render: () => , play: async () => { const root = tailScroller(); + // Measure history publication against rendered content, not the cold + // Markdown module's temporary plain-text layout. + await document.fonts.ready; + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); // Writing zero while the scroller is still at zero is a no-op, so require // the initial pin to have provably moved before exercising the real one. await waitFor(() => { @@ -2866,6 +2870,7 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { wheelUp(root); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await painted(6); expect(Math.abs(turnTop(reading.turnId) - reading.top)).toBeLessThanOrEqual(1); From 3f7d96dd34e8b9bdcbca19d6f4bbdfa52fbf9b65 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 23:16:09 +0800 Subject: [PATCH 08/25] fix(ui): align publication lifecycle with renderer transcript window --- apps/desktop/renderer-architecture.json | 2 +- apps/desktop/src/renderer/app-shell-effects.ts | 10 ++++------ .../controller/use-app-shell-session-ui-state.ts | 4 ++-- packages/ui/src/__tests__/use-chat-scroll.test.tsx | 4 ++-- patches/@astryxdesign+core+0.5.2.patch | 1 - 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index dd7fbe8dea..13989e61a2 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -496,7 +496,7 @@ "react": 1 }, "importSpecifiers": 19, - "nonTriviaTokens": 3740 + "nonTriviaTokens": 3718 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 7, diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 1e52689450..15bd1e236c 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -319,7 +319,7 @@ export function useActiveSessionEvents(options: { completeObservationSeed: (sessionId: string) => void; setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; setMessageLoadPending: (pending: boolean) => void; - publishTranscript: (sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, isDisposed: () => boolean, onReady: () => void) => void; + publishTranscript: (sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, onReady: () => void) => void; transcriptRangeRef: RefBox; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: Pick; @@ -333,14 +333,12 @@ export function useActiveSessionEvents(options: { return next; }); }); - // Reached only from the store subscription, which the effect unsubscribes on - // teardown, so the window it publishes is always a live one. + // Publication rechecks the controller's store identity after any input wait. const applyTranscript = useEffectEvent(( sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, - isDisposed: () => boolean, ) => { - options.publishTranscript(sessionId, store, isDisposed, () => { + options.publishTranscript(sessionId, store, () => { clearMessageLoadError(sessionId); options.setMessageLoadPending(false); }); @@ -406,7 +404,7 @@ export function useActiveSessionEvents(options: { now: Date.now(), }), })); - const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, transcript, () => disposed)); + const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, transcript)); const openTranscript = (signal: AbortSignal) => window.maka.transcripts.open( activeId, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index 08fda315a7..49e8093e4e 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -57,9 +57,9 @@ export function useAppShellSessionUiState boolean, onReady: () => void) { + publishTranscript(sessionId: string, store: TranscriptSource, onReady: () => void) { controller.transcriptViewportNavigation.commitRange(sessionId, () => { - if (isDisposed() || activeIdRef.current !== sessionId) return; + if (transcriptRangeRef.current?.store !== store || activeIdRef.current !== sessionId) return; const snapshot = store.snapshot(); publishMessages([...snapshot.messages]); if (snapshot.ready) onReady(); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index dffe65421f..4347f39ba0 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -358,7 +358,7 @@ test('a fill that issued no read is not chained into another one', async () => { assert.equal(requests, 2, 'the landed read chains one re-check, whose refusal ends it'); }); -test('an older request at offset zero restores the browser anchoring the reader depends on', async () => { +test('an older request at offset zero does not move the reader', async () => { const { document, window } = parseHTML( '
', ); @@ -391,7 +391,7 @@ test('an older request at offset zero restores the browser anchoring the reader transcript.readerScrollTo(0); assert.equal(requests, 1); - assert.equal(transcript.scrollTop, 1, 'keep native anchoring enabled at the start'); + assert.equal(transcript.scrollTop, 0, 'publication owns anchoring; input must not nudge the reader'); }); test('a transcript change re-reads the band while the reader stays at the tail', async () => { diff --git a/patches/@astryxdesign+core+0.5.2.patch b/patches/@astryxdesign+core+0.5.2.patch index cea4da0d6c..a08e3923df 100644 --- a/patches/@astryxdesign+core+0.5.2.patch +++ b/patches/@astryxdesign+core+0.5.2.patch @@ -218,7 +218,6 @@ index eac4c4b..a21c0c4 100644 return def; } function buildLanguageUncached(lang) { - diff --git a/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js b/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js index 4765e55..a93b859 100644 --- a/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js From f4560c0cd04ec0280a7f1e2d554b30b93cde6f88 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 10:59:17 +0800 Subject: [PATCH 09/25] fix(ui): preserve tail following after stationary edge input Generated-by: Codex --- .../transcript-scroll-authority.test.ts | 25 +++++++++++++++++++ .../ui/src/transcript-scroll-authority.tsx | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 0d577e092a..2508476b13 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -243,6 +243,31 @@ test('content that grows under a pinned transcript keeps the tail on screen', () }); }); +test('an upward edge gesture in a short transcript preserves following through publication and growth', () => { + withObservers((resize, frame) => { + const root = fakeRoot({ scrollHeight: 400, clientHeight: 400 }); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + const phases: string[] = []; + authority.subscribeToReaderScroll((phase) => { + phases.push(phase); + if (phase === 'input') authority.commitWhenIdle(() => root.grow(200)); + }); + + root.input(-100); + assert.equal(root.scrollHeight, 400); + frame(); frame(); + assert.deepEqual(phases, ['input', 'settled']); + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.scrollTop, 200); + + root.grow(300); + resize(); + assert.equal(root.scrollTop, 500); + assert.equal(authority.getSnapshot().awayFromTail, false); + }); +}); + test('identical shrink/grow geometry follows only when no reader input intervened', () => { for (const readerInput of [false, true]) { withObservers((resize) => { diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index eec42ec7e1..bb43765eba 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -286,7 +286,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // This schedules no scroll and uses no time-based ignore window. requestAnimationFrame(() => requestAnimationFrame(() => { if (gesture !== ended || ended.top !== top || pointer !== undefined || touchHeld) return; - pinned = ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX; + // Input that can scroll and observed reader movement already release + // the pin. Settling an unmoved edge gesture must not release it too. + pinned = pinned || (ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX); gesture = undefined; flushCommit(); publish(); From 56305b2b466c16dce2f1069fdf28abd865ee3865 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 11:26:34 +0800 Subject: [PATCH 10/25] test(ui): synchronize geometry probes with native input settlement Generated-by: Codex --- apps/desktop/e2e/scroll-geometry.spec.ts | 26 ++++--- .../e2e/transcript-scroll-cost.spec.ts | 68 +++++++++++-------- 2 files changed, 57 insertions(+), 37 deletions(-) diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index 165be933c3..aa384d9a60 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -86,8 +86,14 @@ test('native thumb keeps its geometry and releases history without moving the re }>, }; (window as any).__windowGeometry = state; - root.addEventListener('pointerdown', () => state.pointerDown++); - document.addEventListener('pointerup', () => state.pointerUp++); + root.addEventListener('pointerdown', () => { + state.pointerDown++; + state.held = true; + }); + document.addEventListener('pointerup', () => { + state.pointerUp++; + state.held = false; + }); const frame = () => { const turns = [...root.querySelectorAll('.maka-transcript-turn')]; state.frames.push({ @@ -118,9 +124,6 @@ test('native thumb keeps its geometry and releases history without moving the re 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 page.evaluate(() => { - (window as any).__windowGeometry.held = true; - }); await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: start.x, @@ -158,10 +161,17 @@ test('native thumb keeps its geometry and releases history without moving the re buttons: 0, clickCount: 1, }); - await page.evaluate(() => { - (window as any).__windowGeometry.held = false; + // 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); }); - await page.waitForTimeout(300); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); const result = await page.evaluate(() => { const state = (window as any).__windowGeometry; state.done = true; diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 4b5efff725..8ff4bb8cd2 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -64,7 +64,7 @@ declare global { interface Window { __makaTranscriptDisplacement?: { boundaries: TranscriptBoundary[]; - record(on: boolean): void; + isSettled(): boolean; stop(): void; }; } @@ -149,25 +149,38 @@ async function observeDisplacement(page: Page): Promise { 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(); @@ -224,15 +237,6 @@ async function observeDisplacement(page: Page): Promise { }, SCROLLER); } -/** Opens the measurement window, or closes it around the reader's own gesture. */ -async function recordDisplacement(page: Page, on: boolean): Promise { - 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 { return page.evaluate(() => { const state = window.__makaTranscriptDisplacement; @@ -303,6 +307,10 @@ async function returnToLatest(page: Page): Promise { * range changes, whatever Turn the reader can still see must hold its viewport * position. * + * Each native scroll operation finishes before the next one starts. This + * isolates publication displacement from the reader's own movement; the + * continuous-wheel and held-thumb tests cover input that is still in flight. + * * 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 @@ -328,18 +336,20 @@ 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); + // One native scroll operation at a time: an older scrollend can arrive + // during a newer wheel animation and is not a quiet measurement point. + await wheel(page, cdp, { ticks: 1, deltaY: -1_200 }); + 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((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); } expect(pages).toBeGreaterThan(0); From e81128771eef20dca8639cd225c64924d1511501 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 11:55:05 +0800 Subject: [PATCH 11/25] fix(ui): make transcript publication an atomic scroll boundary Generated-by: Codex --- apps/desktop/e2e/scroll-geometry.spec.ts | 10 ++ apps/desktop/renderer-architecture.json | 6 +- .../app-shell-chat-actions-fixture.ts | 2 +- .../app-shell-first-send-cleanup.test.ts | 33 +++- .../src/renderer/app-shell-chat-actions.ts | 19 +- apps/desktop/src/renderer/app-shell.tsx | 30 +--- .../transcript-scroll-authority.test.ts | 25 --- .../ui/src/__tests__/use-chat-scroll.test.tsx | 168 +++++++++++++++++- .../ui/src/transcript-scroll-authority.tsx | 37 ++-- packages/ui/src/use-chat-scroll.ts | 12 +- 10 files changed, 259 insertions(+), 83 deletions(-) diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index aa384d9a60..72b43fa8f1 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -168,6 +168,12 @@ test('native thumb keeps its geometry and releases history without moving the re 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((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), @@ -178,6 +184,10 @@ test('native thumb keeps its geometry and releases history without moving the re 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)); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 13989e61a2..2b74d8a26b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -329,7 +329,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 3648 + "nonTriviaTokens": 3646 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -729,8 +729,6 @@ "window.maka.settings.subscribeClientChanged": 1 }, "environmentCapabilities": { - "document.querySelector": 1, - "requestAnimationFrame": 1, "window.clearTimeout": 1, "window.requestAnimationFrame": 4, "window.setTimeout": 1 @@ -865,7 +863,7 @@ "react": 1 }, "importSpecifiers": 104, - "nonTriviaTokens": 13531 + "nonTriviaTokens": 13468 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index 8dc33c60bb..cc9cc16198 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -93,11 +93,11 @@ export function createActionsDeps() { }, setActiveId: () => undefined, setMessageLoadErrorBySession: () => undefined, - setMessages: () => undefined, addTransientMessage: () => undefined, updateTransientMessage: () => undefined, removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, + isMessagePublished: (_message: unknown) => false, setInteractionBySession: () => undefined, respondToUserForm: async () => undefined, showModelSetupToast: () => undefined, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 915d08a672..039006af45 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -543,6 +543,38 @@ describe('composer first-send cleanup', () => { } }); + it('refresh waits for durable messages without bypassing range publication', async () => { + const deps = createActionsDeps(); + deps.activeIdRef.current = 'session'; + let directPublications = 0; + let durable = false; + const durableAnswer = { id: 'answer' }; + let publishedAnswer = { id: 'answer' }; + const ready = deferred(); + const controller = { + ready: () => ready.promise, + waitForDurableMessage: async () => { durable = true; return true; }, + store: { + snapshot: () => ({ sessionId: 'session', messages: [durableAnswer] }), + hasDurableMessage: () => durable, + }, + } as unknown as DesktopTranscriptRangeController; + const dependencies = { + ...deps, + transcriptRangeRef: { current: controller }, + isMessagePublished: (message: unknown) => message === publishedAnswer, + setMessages: () => { directPublications++; }, + }; + const actions = createAppShellChatActions(dependencies); + const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); + assert.equal(durable, false); + ready.resolve(); + assert.equal(await refresh, false, 'durability cannot retire the live answer before publication'); + assert.equal(directPublications, 0, 'only the range subscription may publish source changes'); + publishedAnswer = durableAnswer; + assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true); + }); + for (const initialized of [false, true]) { it(`does not navigate the previous Session controller (${initialized ? 'initialized' : 'opening'}) while sending`, async () => { const submissions: string[] = []; @@ -577,7 +609,6 @@ describe('composer first-send cleanup', () => { cancel: () => {}, followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); }, }), - setMessages: () => { assert.fail('the previous range must not replace selected messages'); }, }).send('hello'); assert.equal(result, true); assert.deepEqual(submissions, ['selected-session']); diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 532d4ccbb3..bee65df2f6 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -18,13 +18,13 @@ */ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { StoredMessage } from '@maka/core/session'; import type { CollaborationMode } from '@maka/core/collaboration'; import type * as DesktopBridge from '../preload/bridge-contract.js'; import type { QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; -import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UiLocale } from '@maka/core/ui-locale'; @@ -59,7 +59,6 @@ import { noRealConnectionSetupDescription, } from './model-connection-errors.js'; import type { RefreshMessagesOptions } from './session-message-settlement.js'; -import type { MessageListUpdater } from './session-workspace-actions.js'; export type { RefreshMessagesOptions }; @@ -157,7 +156,6 @@ export function createAppShellChatActions(deps: { activateSessionForFirstSend: (sessionId: string) => Promise; setActiveId: (sessionId: string | undefined) => void; setMessageLoadErrorBySession: MessageLoadErrorUpdater; - setMessages: MessageListUpdater; addTransientMessage: ( sessionId: string, message: TransientUserMessageProjection, @@ -168,6 +166,7 @@ export function createAppShellChatActions(deps: { ) => void; removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; + isMessagePublished: (message: StoredMessage) => boolean; onFollowLatest: (sessionId: string) => Promise; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ @@ -212,7 +211,6 @@ export function createAppShellChatActions(deps: { activateSessionForFirstSend, setActiveId, setMessageLoadErrorBySession, - setMessages, removeTransientMessage, transcriptRangeRef, onFollowLatest, @@ -610,18 +608,21 @@ export function createAppShellChatActions(deps: { if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== controller) { return false; } - const range = controller.store; - const snapshot = range.snapshot(); + const snapshot = controller.store.snapshot(); if (snapshot.sessionId !== sessionId) return false; - const next = [...snapshot.messages]; - setMessages(next); + // Store changes already publish through its active subscription. A + // refresh checks readiness; it must not bypass input-held publication. setMessageLoadErrorBySession((current) => { if (!current[sessionId]) return current; const updated = { ...current }; delete updated[sessionId]; return updated; }); - return requiredMessageId === undefined || range.hasDurableMessage(requiredMessageId); + // The live answer stays visible until the durable answer reaches the + // published view. Its existing publication effect retries this handoff. + return requiredMessageId === undefined || snapshot.messages.some( + (message) => message.id === requiredMessageId && deps.isMessagePublished(message), + ); } catch (error) { if (activeIdRef.current === sessionId) { const message = messageRefreshErrorMessage(error, uiLocale); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ab45d5cbee..a3369358a5 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -895,32 +895,6 @@ function AppShellContent({ uiLocale, }); - // PR109e-e: click handler for lineage badge → scroll target turn into - // view. Avoids pulling a separate ref-tracker: relies on the - // `data-turn-id` attribute the renderer already sets on each TurnView. - // - // @kenji PR109e review + @xuan PR109f follow-up: scrollIntoView with - // `behavior: 'smooth'` must respect both reduced-motion AND the - // e2e-fixture capture entry (PR-IR-02). @xuan confirmed on main that - // e2e-fixture always writes `data-maka-e2e-fixture="true"` but - // `data-maka-reduced-motion="true"` is only set on the reduced - // variant — so the e2e-fixture attribute is the broader signal for - // "deterministic capture, no animations". Three triggers collapse to - // `auto`: - // 1. `data-maka-reduced-motion="true"` — PR-IR-04 reduced variant - // 2. `data-maka-e2e-fixture="true"` — PR-IR-02 any capture - // 3. `prefers-reduced-motion: reduce` — OS-level user preference - function handleLineageBadgeClick(targetTurnId: string) { - requestAnimationFrame(() => { - const el = document.querySelector(`[data-turn-id="${CSS.escape(targetTurnId)}"]`); - if (!el || !('scrollIntoView' in el)) return; - (el as HTMLElement).scrollIntoView({ - behavior: readScrollMotionBehavior(), - block: 'center', - }); - }); - } - const openSessionInChatRef = useRef< (sessionId: string, turnId?: string, sequence?: number) => void >(() => undefined); @@ -1492,12 +1466,12 @@ function AppShellContent({ activateSessionForFirstSend, setActiveId, setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, - setMessages, addTransientMessage, updateTransientMessage, removeTransientMessage, transcriptRangeRef, onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? Promise.resolve(true), + isMessagePublished: (message) => messages.includes(message), setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -2694,7 +2668,7 @@ function AppShellContent({ detail: resumeParkDescriptionBySession[activeId], onResume: () => { void resumeInterruptedSession(); }, } : undefined} - onLineageBadgeClick={handleLineageBadgeClick} + onLineageBadgeClick={(turnId) => { if (activeId) openSessionInChat(activeId, turnId); }} onReadAttachmentBytes={window.maka.attachments.readBytes} onOpenLinkedSession={openSessionInChat} scrollTargetTurn={ diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 2508476b13..0d577e092a 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -243,31 +243,6 @@ test('content that grows under a pinned transcript keeps the tail on screen', () }); }); -test('an upward edge gesture in a short transcript preserves following through publication and growth', () => { - withObservers((resize, frame) => { - const root = fakeRoot({ scrollHeight: 400, clientHeight: 400 }); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - const phases: string[] = []; - authority.subscribeToReaderScroll((phase) => { - phases.push(phase); - if (phase === 'input') authority.commitWhenIdle(() => root.grow(200)); - }); - - root.input(-100); - assert.equal(root.scrollHeight, 400); - frame(); frame(); - assert.deepEqual(phases, ['input', 'settled']); - assert.equal(authority.getSnapshot().pinned, true); - assert.equal(root.scrollTop, 200); - - root.grow(300); - resize(); - assert.equal(root.scrollTop, 500); - assert.equal(authority.getSnapshot().awayFromTail, false); - }); -}); - test('identical shrink/grow geometry follows only when no reader input intervened', () => { for (const readerInput of [false, true]) { withObservers((resize) => { diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 4347f39ba0..57402a773b 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, useRef } from 'react'; +import { act, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import type { StoredMessage } from '@maka/core/session'; @@ -355,7 +355,9 @@ test('a fill that issued no read is not chained into another one', async () => { await act(async () => { transcript.readerScrollTo(0); }); - assert.equal(requests, 2, 'the landed read chains one re-check, whose refusal ends it'); + assert.equal(requests, 1, 'the landed read waits for input settlement'); + await act(() => transcript.scroller.dispatchEvent(new window.Event('scrollend'))); + assert.equal(requests, 2, 'settlement rechecks the published range, whose refusal ends it'); }); test('an older request at offset zero does not move the reader', async () => { @@ -394,6 +396,165 @@ test('an older request at offset zero does not move the reader', async () => { assert.equal(transcript.scrollTop, 0, 'publication owns anchoring; input must not nudge the reader'); }); +test('idle range admission commits the React DOM before a subsequent input can begin', async () => { + const { document, window } = parseHTML('
'); + const { frames } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 400, turnHeight: 400, turnCount: 12, + }); + let authority!: TranscriptScrollAuthority; + let publish!: (value: string) => void; + function Harness() { + const [value, setValue] = useState('old'); + publish = setValue; + authority = useTranscriptScrollAuthority(); + const scrollRef = useRef(transcript.scroller); + useChatScroll({ scrollRef, sessionId: 'admission', messages: [], behavior: 'auto' }); + return {value}; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await act(async () => { + authority.commitWhenIdle(() => publish('new')); + await Promise.resolve(); + assert.equal(document.querySelector('#mount')!.textContent, 'new', + 'an admitted update must not remain in React scheduling after its idle check'); + }); + await act(async () => { + authority.commitWhenIdle(() => publish('held')); + const down = new window.Event('pointerdown'); + Object.defineProperties(down, { + button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, + }); + transcript.scroller.dispatchEvent(down); + await Promise.resolve(); + assert.equal(document.querySelector('#mount')!.textContent, 'new', + 'input that starts before admission must hold the queued update'); + }); + await act(() => document.dispatchEvent(new window.Event('pointerup'))); + await act(() => { + const pending = [...frames.values()]; frames.clear(); + for (const callback of pending) callback(0); + }); + assert.equal(document.querySelector('#mount')!.textContent, 'held'); +}); + +for (const hasOlder of [false, true]) { + test(`stationary upward input ${hasOlder ? 'reads available history' : 'keeps following without history'}`, async () => { + const { document, window } = parseHTML('
'); + const { frames, deliverResizeOf } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 400, turnHeight: 200, turnCount: 1, + }); + let authority!: TranscriptScrollAuthority; + let requests = 0; + function Harness() { + authority = useTranscriptScrollAuthority(); + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, sessionId: 'short', messages: [], behavior: 'auto', + hasOlderHistory: hasOlder, + onPrefetchHistory: () => { requests++; return new Promise(() => {}); }, + }); + return null; + } + const frame = async () => act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await frame(); // Initial fill may already be in flight when the reader asks. + await act(() => wheel(transcript.scroller, -100)); + let publications = 0; + await act(() => authority.commitWhenIdle(() => { + publications++; + transcript.setTurnCount(3); + if (hasOlder) { + [...transcript.scroller.children].forEach((turn, index) => { + (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; + }); + } + })); + assert.equal(publications, 0, 'input holds publication, including an accepted history request'); + await frame(); await frame(); + assert.equal(requests, hasOlder ? 1 : 0); + assert.equal(publications, 1); + assert.equal(authority.getSnapshot().pinned, !hasOlder); + const beforeGrowth = transcript.scrollTop; + await act(() => { + transcript.setTurnCount(4); + deliverResizeOf(transcript.scroller); + }); + assert.equal(transcript.scrollTop, hasOlder ? beforeGrowth : 400); + }); +} + +test('a held fill publishes before trimming or chaining from the new geometry', async () => { + const { document, window } = parseHTML('
'); + const { frames } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 400, turnHeight: 400, turnCount: 12, + }); + let authority!: TranscriptScrollAuthority; + let finishRead!: () => void; + let requests = 0; + let publications = 0; + const retained: string[] = []; + function Harness() { + authority = useTranscriptScrollAuthority(); + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, sessionId: 'held-fill', messages: [], behavior: 'auto', + hasOlderHistory: true, + onPrefetchHistory: () => { + requests++; + return new Promise((resolve) => { + finishRead = () => { + authority.commitWhenIdle(() => { + publications++; + transcript.setTurnCount(14); + [...transcript.scroller.children].forEach((turn, index) => { + (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; + }); + }); + resolve(true); + }; + }); + }, + onRetainWindow: (range) => { retained.push(range.firstTurnId); }, + }); + return null; + } + const frame = async () => act(() => { + const pending = [...frames.values()]; frames.clear(); + for (const callback of pending) callback(0); + }); + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await frame(); + retained.length = 0; + await act(() => { + const down = new window.Event('pointerdown'); + Object.defineProperties(down, { + button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, + }); + transcript.scroller.dispatchEvent(down); + transcript.scroller.scrollTop = 0; + transcript.scroller.dispatchEvent(new window.Event('scroll')); + }); + assert.equal(requests, 1); + await act(() => finishRead()); + assert.equal(publications, 0); + assert.deepEqual(retained, [], 'published IDs cannot trim source while its new page is held'); + assert.equal(requests, 1, 'a held response must not chain reads using stale geometry'); + await act(() => document.dispatchEvent(new window.Event('pointerup'))); + await frame(); await frame(); + assert.equal(publications, 1); + assert.equal(retained.at(-1), 'turn--2', 'the new published band includes the older page'); +}); + test('a transcript change re-reads the band while the reader stays at the tail', async () => { const { document, window } = parseHTML( '
', @@ -485,6 +646,8 @@ test('the retained window is the band around the reader, and an unmounted bookma retained.length = 0; transcript.readerScrollTo(6_000); + assert.deepEqual(retained, [], 'trim waits for the input to finish'); + transcript.scroller.dispatchEvent(new window.Event('scrollend')); assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-5', lastTurnId: 'turn-15' }); // Six screens is the threshold: with less than that beyond the scrollport in @@ -570,6 +733,7 @@ test('a viewport that shrinks trims what it just pushed beyond the band', async transcript.readerScrollTo(4_000); retained.length = 0; transcript.readerScrollTo(4_100); + transcript.scroller.dispatchEvent(new window.Event('scrollend')); assert.deepEqual(retained, [], 'nothing lies six 1000px screens away'); transcript.setClientHeight(400); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index bb43765eba..dffb98b986 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -61,6 +61,8 @@ export interface TranscriptScrollSnapshot { } export interface TranscriptScrollAuthority { + /** Whether native input still holds the published geometry. */ + isInputActive(): boolean; /** Publish the newest resident range after the current input operation ends. */ commitWhenIdle(commit: () => void): void; /** Take the scroller. Returns the detach for the effect that called it. */ @@ -77,8 +79,10 @@ export interface TranscriptScrollAuthority { * the resulting reading position; settled rechecks the final edge after a * gesture. No phase is emitted for layout alone; * consumers do not interpret raw wheel or scroll events themselves. + * Returning true from input accepts history-reading intent, even at an + * unmoving edge. The window owner knows whether adjacent history exists. */ - subscribeToReaderScroll(listener: (phase: 'input' | 'scroll' | 'settled') => void): () => void; + subscribeToReaderScroll(listener: (phase: 'input' | 'scroll' | 'settled', direction?: 'up' | 'down') => boolean | void): () => void; /** * The reading position, measured now and published like any other move. For * a caller that has just moved the viewport or the content itself and cannot @@ -115,11 +119,12 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { let touchHeld = false; const commitRange = (commit: () => void): void => { const target = root; - if (!target || pinned) { commit(); return; } + if (!target) { commit(); return; } + if (pinned) { flushSync(commit); writeToTail(); return; } const top = target.getBoundingClientRect().top; const anchor = [...target.querySelectorAll('[data-turn-id]')] .find((turn) => turn.getBoundingClientRect().bottom > top); - if (!anchor) { commit(); return; } + if (!anchor) { flushSync(commit); return; } const before = anchor.getBoundingClientRect().top; // A gap notice is a poor native anchor: it survives a range replacement // while the paragraph beneath it moves. Restore a content Turn once, with @@ -142,7 +147,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { let readingTurnId: string | undefined; let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail, readingTurnId }; const listeners = new Set<() => void>(); - const readerListeners = new Set<(phase: 'input' | 'scroll' | 'settled') => void>(); + const readerListeners = new Set<(phase: 'input' | 'scroll' | 'settled', direction?: 'up' | 'down') => boolean | void>(); const distanceToTail = (): number => root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; const readTurn = (): string | undefined => { @@ -168,14 +173,21 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { awayFromTail = false; publish(); }; - const reportReader = (phase: 'input' | 'scroll' | 'settled'): void => { - for (const listener of [...readerListeners]) listener(phase); + const reportReader = (phase: 'input' | 'scroll' | 'settled', direction?: 'up' | 'down'): boolean => { + let readingHistory = false; + for (const listener of [...readerListeners]) { + if (listener(phase, direction) === true) readingHistory = true; + } + return readingHistory; }; return { + isInputActive: () => gesture !== undefined || pointer !== undefined || touchHeld, commitWhenIdle(commit) { pendingCommit = commit; - flushCommit(); + // Callers include React effects. Leave that lifecycle, then admit and + // commit synchronously so React cannot carry an idle update into input. + queueMicrotask(flushCommit); }, attach(next) { root = next; @@ -190,13 +202,16 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { if (remaining <= 0) { // An edge gesture can ask for an adjacent history page even though // it produces no scroll (and therefore no scrollend). - reportReader('input'); + if (reportReader('input', direction)) { + pinned = false; + publish(); + } onScrollEnd(); return; } pinned = false; publish(); - reportReader('input'); + reportReader('input', direction); }; const onWheel = (event: WheelEvent): void => { if (event.ctrlKey || event.metaKey || event.deltaY === 0) return; @@ -368,7 +383,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { pointer = undefined; touchHeld = false; pinned = true; - flushCommit(); + queueMicrotask(flushCommit); writeToTail(); publish(); }, @@ -377,7 +392,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { pinned = false; // Commands can originate in a React effect. Publish before their next // positioning frame, outside React's lifecycle, if a range is pending. - if (pendingCommit) requestAnimationFrame(flushCommit); + if (pendingCommit) queueMicrotask(flushCommit); awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); }, diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 6fc0d109e3..bc1feaedb4 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -190,7 +190,7 @@ export function useChatScroll(input: { // Chaining pages needs a re-check here, because the render that the // landed rows caused ran while this direction still counted as in // flight. A prefetch that issued no read has nothing to chain from. - (issued) => { inFlight[direction] = false; if (issued) check(); }, + (issued) => { inFlight[direction] = false; if (issued && !authority.isInputActive()) check(); }, () => { inFlight[direction] = false; }, ); }; @@ -201,6 +201,9 @@ export function useChatScroll(input: { const below = root.scrollHeight - root.clientHeight - root.scrollTop; if (canLoad('up') && above < screen * 2) requestHistory('up'); if (canLoad('down') && below < screen * 2) requestHistory('down'); + // Source pages may have arrived without entering the DOM yet. Its old + // IDs cannot trim that source; settled rechecks after publication. + if (authority.isInputActive()) return; if (above <= screen * 6 && below <= screen * 6) return; const rect = root.getBoundingClientRect(); const turns = [...root.querySelectorAll('[data-turn-id]')]; @@ -226,7 +229,12 @@ export function useChatScroll(input: { bandCheck.current = check; // Both phases matter: a gesture at an edge moves nothing and so reports // only `input`, and that is exactly where the next page is wanted. - const stopWatchingReader = authority.subscribeToReaderScroll(() => check()); + const stopWatchingReader = authority.subscribeToReaderScroll((phase, direction) => { + check(); + // An existing fill also satisfies this request. Preserve the gesture + // while giving the authority the intent that pixels alone cannot tell. + return phase === 'input' && direction === 'up' && canLoad('up'); + }); // A resize redefines the band itself — the screen it counts in is the // root's own height — while the reader and the messages stand still. The // authority publishes only when its snapshot changes, so a resize that From 5688482c61b393571a73283a7f0b1000386604b3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 13:07:27 +0800 Subject: [PATCH 12/25] fix(ui): retain transcript publication beyond viewport lifetime Generated-by: Codex --- .github/workflows/ci.yml | 1 - .../__tests__/workhub-send-visibility.test.ts | 31 ++ .../controller/use-workhub-controller.ts | 24 +- .../transcript-scroll-authority.test.ts | 18 +- .../ui/src/__tests__/use-chat-scroll.test.tsx | 46 ++- packages/ui/src/chat-turn.tsx | 6 +- packages/ui/src/tool-activity.tsx | 4 +- .../ui/src/transcript-scroll-authority.tsx | 37 +- .../ui/src/transcript-viewport-navigation.ts | 42 ++- packages/ui/src/use-chat-scroll.ts | 2 +- scripts/perf/CI.md | 18 +- scripts/perf/geometry-ablation.mjs | 320 ++++++++---------- scripts/perf/geometry-navigation.spec.ts | 56 ++- scripts/perf/geometry-results.md | 20 +- 14 files changed, 337 insertions(+), 288 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8e52aa107..bcc0b0c4d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,7 +446,6 @@ jobs: - name: Transcript geometry invariants if: steps.plan.outputs.storybook == 'true' env: - GEOMETRY_MODE: baseline GEOMETRY_REPETITIONS: '1' run: xvfb-run -a node scripts/perf/geometry-ablation.mjs --assert-stable diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 86b33fd16c..439998817d 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -243,6 +243,37 @@ test('WorkHub shows the submitted prompt before admission and keeps it until its h.latestRead.resolve(); }); +test('WorkHub holds transcript and live handoff together until publication is admitted', async () => { + const h = await mountController(); + let sent!: Promise; + await act(() => { sent = h.controller.send('held prompt', []); }); + const turnId = h.requests[0]!.turnId; + await act(async () => { h.admission.resolve({ turnId }); await sent; }); + await act(() => h.emit({ type: 'text_delta', id: 'delta', turnId, messageId: 'answer', ts: 1, text: 'Answer' })); + let held = true; + let idle!: () => void; + const detach = h.controller.viewportNavigation.attachCommitScheduler(h.sessionId, { + commitIfIdle(commit) { if (held) return false; commit(); return true; }, + subscribeToIdle(listener) { idle = listener; return () => {}; }, + }); + const messages: StoredMessage[] = [ + { type: 'user', id: 'user', turnId, text: 'held prompt', ts: 1 }, + { type: 'assistant', id: 'answer', turnId, text: 'Answer', ts: 2, modelId: 'fixture' }, + ]; + await act(() => h.publish(messages)); + await act(() => h.controller.streamingSettled('answer')); + assert.equal(h.controller.transcript.messages.length, 0); + assert.equal(h.controller.transientMessages.length, 1); + assert.ok(h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); + await act(() => { held = false; idle(); }); + assert.deepEqual(h.controller.transcript.messages, messages); + assert.equal(h.controller.transientMessages.length, 0); + await act(() => h.controller.streamingSettled('answer')); + assert.ok(!h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); + detach(); + h.latestRead.resolve(); +}); + test('WorkHub removes a failed submission from the conversation and preserves its retry identity', async () => { const h = await mountController(); let sent!: Promise; diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index d9c6d0df0a..2795fdd52e 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -63,6 +63,7 @@ export function useWorkHubController() { ); const [choices, setChoices] = useState([]); const [transcript, setTranscript] = useState(emptyTranscript); + // Reconciliation reads the published view, never a source page held by input. const transcriptRef = useRef(emptyTranscript); const [viewportNavigation] = useState(createTranscriptViewportNavigation); const [transientMessages, setTransientMessages] = useState([]); @@ -328,9 +329,6 @@ export function useWorkHubController() { ); const opening = services.openTranscript(sessionId, (snapshot) => { if (disposed) return; - transcriptRef.current = snapshot; - setTranscript(snapshot); - if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); const attempt = pendingSend.current; if (attempt?.sessionId === sessionId) { const messages = snapshot.messages.filter((message) => message.turnId === attempt.input.turnId); @@ -340,13 +338,19 @@ export function useWorkHubController() { const queued = pendingQueued.current; if (queued?.sessionId === sessionId && snapshot.messages.some((message) => message.type === 'user' && message.id === queued.messageId)) queued.observed = true; - setTransientMessages((previous) => previous.filter((pending) => - !snapshot.messages.some((message) => message.type === 'user' && - (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), - )); - setLiveTurns((previous) => - previous ? reconcileLiveTurnBuffer(previous, [...snapshot.messages]) : previous, - ); + viewportNavigation.commitRange(sessionId, () => { + if (disposed) return; + transcriptRef.current = snapshot; + setTranscript(snapshot); + if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); + setTransientMessages((previous) => previous.filter((pending) => + !snapshot.messages.some((message) => message.type === 'user' && + (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), + )); + setLiveTurns((previous) => + previous ? reconcileLiveTurnBuffer(previous, [...snapshot.messages]) : previous, + ); + }); }, transcriptAbort.signal, readFailed); void opening .then((opened) => { diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 0d577e092a..9ec09f00b7 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -22,6 +22,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; +import { createTranscriptViewportNavigation } from '../transcript-viewport-navigation.js'; interface FakeTurn { turnId: string; @@ -188,12 +189,14 @@ test('a held scrollbar coalesces range publication until release, including a st const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + publication.attachCommitScheduler('session', authority); const commits: number[] = []; root.grabScrollbar(); root.scrollTop -= 100; root.emitScroll(); - authority.commitWhenIdle(() => commits.push(1)); - authority.commitWhenIdle(() => commits.push(2)); + publication.commitRange('session', () => commits.push(1)); + publication.commitRange('session', () => commits.push(2)); root.end(); frame(); frame(); assert.deepEqual(commits, []); root.ownerDocument.dispatchEvent(new Event('pointerup')); @@ -202,27 +205,26 @@ test('a held scrollbar coalesces range publication until release, including a st }); }); -test('an edge wheel without scrollend publishes after input settles and detach drops pending work', () => { +test('an edge wheel without scrollend publishes after input settles', () => { withObservers((_resize, frame) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); const detach = authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + const detachPublication = publication.attachCommitScheduler('session', authority); root.scrollTop = 0; let commits = 0; const phases: string[] = []; authority.subscribeToReaderScroll((phase) => { phases.push(phase); - if (phase === 'input') authority.commitWhenIdle(() => commits++); + if (phase === 'input') publication.commitRange('session', () => commits++); }); root.input(-100); assert.equal(commits, 0); frame(); frame(); assert.equal(commits, 1); assert.deepEqual(phases, ['input', 'settled']); - root.grabScrollbar(); - authority.commitWhenIdle(() => commits++); - detach(); frame(); frame(); - assert.equal(commits, 1); + detach(); detachPublication(); }); }); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 57402a773b..d1ca5298dc 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -397,6 +397,7 @@ test('an older request at offset zero does not move the reader', async () => { }); test('idle range admission commits the React DOM before a subsequent input can begin', async () => { + const navigation = createTranscriptViewportNavigation(); const { document, window } = parseHTML('
'); const { frames } = installScrollTestEnvironment(document, window); const transcript = createTranscript(document, window, { @@ -409,19 +410,19 @@ test('idle range admission commits the React DOM before a subsequent input can b publish = setValue; authority = useTranscriptScrollAuthority(); const scrollRef = useRef(transcript.scroller); - useChatScroll({ scrollRef, sessionId: 'admission', messages: [], behavior: 'auto' }); + useChatScroll({ scrollRef, sessionId: 'admission', messages: [], behavior: 'auto', viewportNavigation: navigation }); return {value}; } mountedRoot = createRoot(document.querySelector('#mount')!); await act(() => mountedRoot?.render()); await act(async () => { - authority.commitWhenIdle(() => publish('new')); + navigation.commitRange('admission', () => publish('new')); await Promise.resolve(); assert.equal(document.querySelector('#mount')!.textContent, 'new', 'an admitted update must not remain in React scheduling after its idle check'); }); await act(async () => { - authority.commitWhenIdle(() => publish('held')); + navigation.commitRange('admission', () => publish('held')); const down = new window.Event('pointerdown'); Object.defineProperties(down, { button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, @@ -439,8 +440,38 @@ test('idle range admission commits the React DOM before a subsequent input can b assert.equal(document.querySelector('#mount')!.textContent, 'held'); }); +test('a source publication survives viewport unmount without another source update', async () => { + const { document, window } = parseHTML('
'); + installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { clientHeight: 400, turnHeight: 400, turnCount: 12 }); + const navigation = createTranscriptViewportNavigation(); + let publish!: (value: string) => void; + let show!: (value: boolean) => void; + function Surface() { + const scrollRef = useRef(transcript.scroller); + useChatScroll({ scrollRef, sessionId: 'session', messages: [], behavior: 'auto', viewportNavigation: navigation }); + return null; + } + function Harness() { + const [value, setValue] = useState('old'); + const [visible, setVisible] = useState(true); + publish = setValue; show = setVisible; + return <>{value}{visible && }; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await act(() => wheel(transcript.scroller, -100)); + await act(() => navigation.commitRange('session', () => publish('latest'))); + assert.equal(document.querySelector('#mount')!.textContent, 'old'); + await act(() => show(false)); + assert.equal(document.querySelector('#mount')!.textContent, 'latest'); + await act(() => show(true)); + assert.equal(document.querySelector('#mount')!.textContent, 'latest'); +}); + for (const hasOlder of [false, true]) { test(`stationary upward input ${hasOlder ? 'reads available history' : 'keeps following without history'}`, async () => { + const navigation = createTranscriptViewportNavigation(); const { document, window } = parseHTML('
'); const { frames, deliverResizeOf } = installScrollTestEnvironment(document, window); const transcript = createTranscript(document, window, { @@ -452,7 +483,7 @@ for (const hasOlder of [false, true]) { authority = useTranscriptScrollAuthority(); const scrollRef = useRef(transcript.scroller); useChatScroll({ - scrollRef, sessionId: 'short', messages: [], behavior: 'auto', + scrollRef, sessionId: 'short', messages: [], behavior: 'auto', viewportNavigation: navigation, hasOlderHistory: hasOlder, onPrefetchHistory: () => { requests++; return new Promise(() => {}); }, }); @@ -468,7 +499,7 @@ for (const hasOlder of [false, true]) { await frame(); // Initial fill may already be in flight when the reader asks. await act(() => wheel(transcript.scroller, -100)); let publications = 0; - await act(() => authority.commitWhenIdle(() => { + await act(() => navigation.commitRange('short', () => { publications++; transcript.setTurnCount(3); if (hasOlder) { @@ -492,6 +523,7 @@ for (const hasOlder of [false, true]) { } test('a held fill publishes before trimming or chaining from the new geometry', async () => { + const navigation = createTranscriptViewportNavigation(); const { document, window } = parseHTML('
'); const { frames } = installScrollTestEnvironment(document, window); const transcript = createTranscript(document, window, { @@ -506,13 +538,13 @@ test('a held fill publishes before trimming or chaining from the new geometry', authority = useTranscriptScrollAuthority(); const scrollRef = useRef(transcript.scroller); useChatScroll({ - scrollRef, sessionId: 'held-fill', messages: [], behavior: 'auto', + scrollRef, sessionId: 'held-fill', messages: [], behavior: 'auto', viewportNavigation: navigation, hasOlderHistory: true, onPrefetchHistory: () => { requests++; return new Promise((resolve) => { finishRead = () => { - authority.commitWhenIdle(() => { + navigation.commitRange('held-fill', () => { publications++; transcript.setTurnCount(14); [...transcript.scroller.children].forEach((turn, index) => { diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index b1717de092..4fc4de0ceb 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1167,7 +1167,7 @@ const AssistantAnswerBubble = memo(function AssistantAnswerBubble(props: Assista return ( {entries.map((entry, index) => ( ) : ( @@ -439,7 +439,7 @@ function LinkedAgentList(props: { const activityCopy = getToolActivityCopy(props.locale); const copy = activityCopy.agent; return ( - + {props.rows.map((row) => { const childSessionId = row.childSessionId; const open = childSessionId && props.onOpenLinkedSession diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index dffb98b986..0523d3fb80 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -63,8 +63,9 @@ export interface TranscriptScrollSnapshot { export interface TranscriptScrollAuthority { /** Whether native input still holds the published geometry. */ isInputActive(): boolean; - /** Publish the newest resident range after the current input operation ends. */ - commitWhenIdle(commit: () => void): void; + /** Synchronously publish and preserve geometry if native input permits it. */ + commitIfIdle(commit: () => void): boolean; + subscribeToIdle(listener: () => void): () => void; /** Take the scroller. Returns the detach for the effect that called it. */ attach(root: HTMLElement | null): () => void; /** One-shot: put the tail back under the reader and follow it again. */ @@ -114,7 +115,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // Geometry belongs to a known input operation, never the other way around. // scrollend also covers smooth keyboard scrolling and touchpad inertia. let gesture: { top: number; direction?: 'up' | 'down' } | undefined; - let pendingCommit: (() => void) | undefined; + const idleListeners = new Set<() => void>(); let pointer: number | undefined; let touchHeld = false; const commitRange = (commit: () => void): void => { @@ -138,11 +139,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { target.style.overflowAnchor = pinned ? 'none' : 'auto'; } }; - const flushCommit = (): void => { + const notifyIdle = (): void => { if (gesture || pointer !== undefined || touchHeld) return; - const commit = pendingCommit; - pendingCommit = undefined; - if (commit) commitRange(commit); + for (const listener of [...idleListeners]) listener(); }; let readingTurnId: string | undefined; let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail, readingTurnId }; @@ -183,11 +182,14 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { return { isInputActive: () => gesture !== undefined || pointer !== undefined || touchHeld, - commitWhenIdle(commit) { - pendingCommit = commit; - // Callers include React effects. Leave that lifecycle, then admit and - // commit synchronously so React cannot carry an idle update into input. - queueMicrotask(flushCommit); + commitIfIdle(commit) { + if (gesture || pointer !== undefined || touchHeld) return false; + commitRange(commit); + return true; + }, + subscribeToIdle(listener) { + idleListeners.add(listener); + return () => { idleListeners.delete(listener); }; }, attach(next) { root = next; @@ -248,7 +250,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { requestAnimationFrame(() => { if (gesture !== pending || pending.direction !== undefined) return; gesture = undefined; - flushCommit(); + notifyIdle(); if (pinned) writeToTail(); }); }; @@ -264,7 +266,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { } touchY = nextY; }; - const onTouchEnd = (): void => { touchY = undefined; touchHeld = false; onScrollEnd(); flushCommit(); }; + const onTouchEnd = (): void => { touchY = undefined; touchHeld = false; onScrollEnd(); notifyIdle(); }; const onScroll = (): void => { awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; readingTurnId = readTurn(); @@ -305,7 +307,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // the pin. Settling an unmoved edge gesture must not release it too. pinned = pinned || (ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX); gesture = undefined; - flushCommit(); + notifyIdle(); publish(); if (pinned) writeToTail(); // An anchor navigation can supersede the last in-flight page while @@ -374,7 +376,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { gesture = undefined; pointer = undefined; touchHeld = false; - pendingCommit = undefined; if (root === target) root = null; }; }, @@ -383,7 +384,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { pointer = undefined; touchHeld = false; pinned = true; - queueMicrotask(flushCommit); + queueMicrotask(notifyIdle); writeToTail(); publish(); }, @@ -392,7 +393,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { pinned = false; // Commands can originate in a React effect. Publish before their next // positioning frame, outside React's lifecycle, if a range is pending. - if (pendingCommit) queueMicrotask(flushCommit); + queueMicrotask(notifyIdle); awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); }, diff --git a/packages/ui/src/transcript-viewport-navigation.ts b/packages/ui/src/transcript-viewport-navigation.ts index 9dfa8b8883..bc731ed286 100644 --- a/packages/ui/src/transcript-viewport-navigation.ts +++ b/packages/ui/src/transcript-viewport-navigation.ts @@ -18,22 +18,42 @@ */ /** Bridge the active surface's scroll authority to conversation commands and publication. - * No geometry or pending range state lives here; detaching invalidates queued callbacks. */ + * Publication outlives a viewport: only the source owner can invalidate its data. */ export function createTranscriptViewportNavigation() { const listeners = new Set<(sessionId: string) => void>(); - let commitScheduler: { sessionId: string; schedule: (commit: () => void) => void } | undefined; + let viewport: { sessionId: string; commitIfIdle: (commit: () => void) => boolean } | undefined; + let pending: { sessionId: string; commit: () => void } | undefined; + const drain = (): void => { + const update = pending; + if (!update) return; + const commit = () => { + pending = undefined; + update.commit(); + }; + if (viewport?.sessionId === update.sessionId) viewport.commitIfIdle(commit); + else commit(); + }; return { - attachCommitScheduler(sessionId: string, schedule: (commit: () => void) => void): () => void { - const attached = { sessionId, schedule }; - commitScheduler = attached; - return () => { if (commitScheduler === attached) commitScheduler = undefined; }; + attachCommitScheduler(sessionId: string, authority: { + commitIfIdle(commit: () => void): boolean; + subscribeToIdle(listener: () => void): () => void; + }): () => void { + const attached = { sessionId, commitIfIdle: authority.commitIfIdle }; + viewport = attached; + const unsubscribe = authority.subscribeToIdle(drain); + queueMicrotask(drain); + return () => { + unsubscribe(); + if (viewport !== attached) return; + viewport = undefined; + // React cleanup may be running. Publish after it, without depending + // on a future source emission or a replacement viewport mounting. + queueMicrotask(drain); + }; }, commitRange(sessionId: string, commit: () => void): void { - const attached = commitScheduler; - if (attached?.sessionId === sessionId) attached.schedule(() => { - if (commitScheduler === attached) commit(); - }); - else commit(); + pending = { sessionId, commit }; + queueMicrotask(drain); }, followLatest(sessionId: string): void { for (const listener of [...listeners]) listener(sessionId); diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index bc1feaedb4..103df374c8 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -109,7 +109,7 @@ export function useChatScroll(input: { useEffect(() => authority.attach(input.scrollRef.current), [authority, input.scrollRef]); useEffect(() => input.sessionId - ? input.viewportNavigation?.attachCommitScheduler(input.sessionId, authority.commitWhenIdle) + ? input.viewportNavigation?.attachCommitScheduler(input.sessionId, authority) : undefined, [authority, input.sessionId, input.viewportNavigation]); // A new conversation either resumes a semantic reading position or arrives diff --git a/scripts/perf/CI.md b/scripts/perf/CI.md index 18ea53d324..8ae4410bd7 100644 --- a/scripts/perf/CI.md +++ b/scripts/perf/CI.md @@ -39,20 +39,19 @@ Protocol `warm-projection-codec-ms` retains production wire decoding but exclude - DOM-ready latency includes Playwright polling/IPC. Stream lag starts at renderer subscription delivery and ends at DOM mutation; neither is proof of pixels presented. These are laboratory measurements, not whole-page INP. Long tasks and CPU TaskDuration do not substitute for anchor geometry, energy watts or OS wakeups. - Repeated actions use a warm process. First-action samples are labelled separately, not called cold application startup. Storybook reloads for cold-scroll trials, then repeats expansion and closure in the same mounted story. Heap readings include garbage awaiting collection and do not establish a leak by themselves. Idle retains ten approximately one-second CPU/heap samples and the CPU profile; task duration is normalized by the actual CDP timestamp interval. Browser-context traces retain DOM snapshots, and their overhead is included in the laboratory timings. Long-task/LoAF rows labelled case-total include every operation after observer installation in that case. Actual disk-cold startup, RTT injection, physical presentation, wakeups and long-duration leak acceptance remain outside this first harness. -## #5184 layout ablation +## #5184 production layout measurements -The frontend lane also runs the ordinary-layout candidate against current CSS, -without changing product behavior. `geometry-navigation.spec.ts` reloads six -renderer documents in one Desktop + Host process, alternating baseline and -no-skipping configurations (three each). It measures renderer mount, older +The frontend lane measures the current production layout. +`geometry-navigation.spec.ts` reloads three renderer documents in one Desktop ++ Host process. It measures renderer mount, older history and return to latest separately, including CDP task/layout time and long tasks. The long-task observer has a deliberate busy-task control after measurement. Mount is a warm-process document reload, not disk-cold startup; history uses the existing prompt rail, not wheel-triggered fill/trim. `node scripts/perf/geometry-ablation.mjs` runs the three fixed-range stories -(mixed 24 turns, 45 tools, 1200-line code), three configurations and three -rotating repetitions. Its mount CPU/layout counters are recorded before the +(mixed 24 turns, 45 tools, 1200-line code), with three repetitions each. +Its mount CPU/layout counters are recorded before the first upward sweep, so deferred work cannot disappear from the comparison. The script emits the shared report format and full per-frame geometry JSON. Both probes run sequentially in the existing frontend job and upload through @@ -60,11 +59,10 @@ its existing artifact step. No additional workflow or production switch exists. Reports are `frontend-geometry-navigation.{json,md}` and `frontend-geometry-ablation.{json,md}`, plus `geometry-ablation.json`. -Compare baseline/no-skip within the same job, including every raw sample; +Compare production reports from the baseline and candidate commits, including every raw sample; three samples do not establish a robust p95. CI success means the scenarios and measurements worked, not that a timing budget or product geometry contract -passed. Keep the ordinary-layout change as a candidate until these costs have -been reviewed. The separate `--assert-stable` flag remains an explicit geometry +passed. The separate `--assert-stable` flag remains an explicit geometry assertion, not a hidden performance threshold. ## Validating a new workflow before merge diff --git a/scripts/perf/geometry-ablation.mjs b/scripts/perf/geometry-ablation.mjs index beb02fbd17..55d07cc876 100644 --- a/scripts/perf/geometry-ablation.mjs +++ b/scripts/perf/geometry-ablation.mjs @@ -54,12 +54,9 @@ if (process.versions.electron) { ['performance-45-tools', 1], ['geometry-long-code', 1], ].filter(([id]) => !process.env.GEOMETRY_SCENE || id === process.env.GEOMETRY_SCENE); - const modes = ['baseline', 'no-turn-skip', 'no-skip'].filter( - (mode) => !process.env.GEOMETRY_MODE || mode === process.env.GEOMETRY_MODE, - ); - if (!scenes.length || !modes.length || !Number.isInteger(repetitions) || repetitions < 1) { + if (!scenes.length || !Number.isInteger(repetitions) || repetitions < 1) { await server.close(); - throw new Error('Invalid geometry scene, mode or repetition count'); + throw new Error('Invalid geometry scene or repetition count'); } const rows = []; try { @@ -71,17 +68,9 @@ if (process.versions.electron) { const browser = await cdp.send('Browser.getVersion'); await cdp.send('Performance.enable'); await page.addInitScript(() => { - const mode = new URL(location.href).searchParams.get('ablation'); const style = document.createElement('style'); - const turn = '.maka-transcript-turn'; - // The inline intrinsic-size declaration belongs to Astryx CodeChunk. - const boundaries = `${turn}, [data-maka-transcript-boundary], .astryx-codeblock [style*="contain-intrinsic-block-size"]`; style.textContent = `*, *::before, *::after { transition:none !important; animation:none !important; } - [data-chat-scroll-container] { scroll-behavior:auto !important; } - ${mode === 'no-turn-skip' ? turn : mode === 'no-skip' ? boundaries : ':not(*)'} { - content-visibility:visible !important; - contain:layout style paint !important; - }`; + [data-chat-scroll-container] { scroll-behavior:auto !important; }`; const attach = () => { if (document.documentElement) document.documentElement.append(style); }; @@ -153,150 +142,137 @@ if (process.versions.electron) { }); for (const [scene, turns] of scenes) { for (let trial = 0; trial < repetitions; trial++) { - // Rotate the order so a cold module cache cannot always favour one mode. - for (const mode of [ - ...modes.slice(trial % modes.length), - ...modes.slice(0, trial % modes.length), - ]) { - await page.goto( - `${server.baseUrl}/iframe.html?id=product-shell-official-appshell--${scene}&viewMode=story&ablation=${mode}`, - ); - await expect(page.locator('.maka-transcript-turn')).toHaveCount(turns); - await page.evaluate(() => document.fonts.ready); - await expect(page.locator('.maka-markdown-pending')).toHaveCount(0); - await expect - .poll(async () => { - const m = await metrics(); - return m.h - m.v - m.t; - }) - .toBeLessThanOrEqual(4); - const initial = await metrics(); - expect(initial.h).toBeGreaterThan(initial.v * 3); - const start = await page.evaluate(() => { - window.__geometry.phase = 'cold-up'; - return performance.now(); - }); - const beforeCpu = await cdp.send('Performance.getMetrics'); - const box = await page.locator('[data-chat-scroll-container]').boundingBox(); - const steps = []; - const sweep = async (phase, deltaY) => { - await page.evaluate((phase) => { - window.__geometry.phase = phase; - }, phase); - for (let tick = 0; tick < 350; tick++) { - const before = await metrics(); - if (deltaY < 0 ? before.t <= 1 : before.h - before.v - before.t <= 1) return; - // elementFromPoint selects only an already visible leaf. Never - // measure the offscreen descendants to find an anchor. - await page.evaluate( - ({ x, y }) => { - const el = document.elementFromPoint(x, y); - window.__anchor = { el, top: el?.getBoundingClientRect().top }; - }, - { x: box.x + box.width / 2, y: box.y + box.height / 2 }, - ); - await cdp.send('Input.dispatchMouseEvent', { - type: 'mouseWheel', - x: box.x + box.width / 2, - y: box.y + box.height / 2, - deltaX: 0, - deltaY, - }); - await paint(); - const after = await metrics(); - const moved = await page.evaluate(() => { - const a = window.__anchor; - return a.el?.isConnected ? a.el.getBoundingClientRect().top - a.top : null; - }); - steps.push({ phase, tick, before, after, moved }); - expect(after.count, 'fixed fixture membership changed').toBe(turns); - } - throw new Error( - `${scene}/${mode}/${phase} did not reach the edge within 350 wheel ticks`, + await page.goto( + `${server.baseUrl}/iframe.html?id=product-shell-official-appshell--${scene}&viewMode=story`, + ); + await expect(page.locator('.maka-transcript-turn')).toHaveCount(turns); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('.maka-markdown-pending')).toHaveCount(0); + await expect + .poll(async () => { + const m = await metrics(); + return m.h - m.v - m.t; + }) + .toBeLessThanOrEqual(4); + const initial = await metrics(); + expect(initial.h).toBeGreaterThan(initial.v * 3); + const start = await page.evaluate(() => { + window.__geometry.phase = 'cold-up'; + return performance.now(); + }); + const beforeCpu = await cdp.send('Performance.getMetrics'); + const box = await page.locator('[data-chat-scroll-container]').boundingBox(); + const steps = []; + const sweep = async (phase, deltaY) => { + await page.evaluate((phase) => { + window.__geometry.phase = phase; + }, phase); + for (let tick = 0; tick < 350; tick++) { + const before = await metrics(); + if (deltaY < 0 ? before.t <= 1 : before.h - before.v - before.t <= 1) return; + // elementFromPoint selects only an already visible leaf. Never + // measure the offscreen descendants to find an anchor. + await page.evaluate( + ({ x, y }) => { + const el = document.elementFromPoint(x, y); + window.__anchor = { el, top: el?.getBoundingClientRect().top }; + }, + { x: box.x + box.width / 2, y: box.y + box.height / 2 }, ); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: box.x + box.width / 2, + y: box.y + box.height / 2, + deltaX: 0, + deltaY, + }); + await paint(); + const after = await metrics(); + const moved = await page.evaluate(() => { + const a = window.__anchor; + return a.el?.isConnected ? a.el.getBoundingClientRect().top - a.top : null; + }); + steps.push({ phase, tick, before, after, moved }); + expect(after.count, 'fixed fixture membership changed').toBe(turns); + } + throw new Error(`${scene}/${phase} did not reach the edge within 350 wheel ticks`); + }; + await sweep('cold-up', -600); + await sweep('warm-down', 600); + await sweep('warm-up', -600); + const afterCpu = await cdp.send('Performance.getMetrics'); + await page.evaluate(() => { + window.__geometry.phase = 'done'; + }); + const state = await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]'); + return { + ...window.__geometry, + liveNodes: root.querySelectorAll('*').length, + remainingAuto: [...root.querySelectorAll('*')].filter( + (el) => getComputedStyle(el).contentVisibility === 'auto', + ).length, }; - await sweep('cold-up', -600); - await sweep('warm-down', 600); - await sweep('warm-up', -600); - const afterCpu = await cdp.send('Performance.getMetrics'); - await page.evaluate(() => { - window.__geometry.phase = 'done'; - }); - const state = await page.evaluate(() => { - const root = document.querySelector('[data-chat-scroll-container]'); - return { - ...window.__geometry, - liveNodes: root.querySelectorAll('*').length, - remainingAuto: [...root.querySelectorAll('*')].filter( - (el) => getComputedStyle(el).contentVisibility === 'auto', - ).length, - }; - }); - if (mode === 'no-skip') expect(state.remainingAuto, 'missed a lazy boundary').toBe(0); - const up = state.frames.filter((f) => f.phase === 'cold-up'); - const heights = [initial.h, ...up.map((f) => f.h)]; - const maxReverse = Math.max(0, ...up.slice(1).map((f, i) => f.t - up[i].t)); - const metric = (list, name) => list.metrics.find((m) => m.name === name)?.value ?? 0; - const row = { + }); + expect(state.remainingAuto, 'missed a lazy boundary').toBe(0); + const up = state.frames.filter((f) => f.phase === 'cold-up'); + const heights = [initial.h, ...up.map((f) => f.h)]; + const maxReverse = Math.max(0, ...up.slice(1).map((f, i) => f.t - up[i].t)); + const metric = (list, name) => list.metrics.find((m) => m.name === name)?.value ?? 0; + const row = { + scene, + trial, + initial, + readyMs: start, + // CDP duration counters reset on document navigation. + mountLayoutMs: metric(beforeCpu, 'LayoutDuration') * 1000, + mountTaskMs: metric(beforeCpu, 'TaskDuration') * 1000, + firstRootMs: state.firstRootMs, + heightDrift: Math.max(...heights) - Math.min(...heights), + maxReverse, + maxTaskMs: Math.max(0, ...state.tasks.map((t) => t.duration)), + scrollMaxTaskMs: Math.max( + 0, + ...state.tasks.filter((t) => t.start >= start).map((t) => t.duration), + ), + layoutMs: + (metric(afterCpu, 'LayoutDuration') - metric(beforeCpu, 'LayoutDuration')) * 1000, + heap: await cdp.send('Runtime.getHeapUsage'), + steps, + ...state, + }; + rows.push(row); + await mkdir(path.dirname(output), { recursive: true }); + await writeFile( + output, + JSON.stringify( + { + browser, + viewport: '1200x900', + repetitions, + conditions: + 'Same Electron; alternating fresh DOMs; fonts and Markdown ready; no offscreen box reads before traversal; real CDP wheel. Containment preserved; only skipping removed. Synthetic fixed-range production stories, no Host or paging.', + rows, + }, + null, + 2, + ), + ); + console.log( + JSON.stringify({ scene, - mode, trial, - initial, - readyMs: start, - // CDP duration counters reset on document navigation. - mountLayoutMs: metric(beforeCpu, 'LayoutDuration') * 1000, - mountTaskMs: metric(beforeCpu, 'TaskDuration') * 1000, - firstRootMs: state.firstRootMs, - heightDrift: Math.max(...heights) - Math.min(...heights), + heightDrift: row.heightDrift, maxReverse, - maxTaskMs: Math.max(0, ...state.tasks.map((t) => t.duration)), - scrollMaxTaskMs: Math.max( - 0, - ...state.tasks.filter((t) => t.start >= start).map((t) => t.duration), - ), - layoutMs: - (metric(afterCpu, 'LayoutDuration') - metric(beforeCpu, 'LayoutDuration')) * 1000, - heap: await cdp.send('Runtime.getHeapUsage'), - steps, - ...state, - }; - rows.push(row); - await mkdir(path.dirname(output), { recursive: true }); - await writeFile( - output, - JSON.stringify( - { - browser, - viewport: '1200x900', - repetitions, - conditions: - 'Same Electron; alternating fresh DOMs; fonts and Markdown ready; no offscreen box reads before traversal; real CDP wheel. Containment preserved; only skipping removed. Synthetic fixed-range production stories, no Host or paging.', - rows, - }, - null, - 2, - ), - ); - console.log( - JSON.stringify({ - scene, - mode, - trial, - heightDrift: row.heightDrift, - maxReverse, - readyMs: Math.round(start), - maxTaskMs: row.maxTaskMs, - scrollMaxTaskMs: row.scrollMaxTaskMs, - layoutMs: Math.round(row.layoutMs), - }), - ); - if (process.argv.includes('--assert-stable')) { - expect( - row.heightDrift, - `${scene}/${mode}: fixed-range height drift`, - ).toBeLessThanOrEqual(1); - expect(maxReverse, `${scene}/${mode}: upward scroll reversed`).toBeLessThanOrEqual(1); - } + readyMs: Math.round(start), + maxTaskMs: row.maxTaskMs, + scrollMaxTaskMs: row.scrollMaxTaskMs, + layoutMs: Math.round(row.layoutMs), + }), + ); + if (process.argv.includes('--assert-stable')) { + expect(row.heightDrift, `${scene}: fixed-range height drift`).toBeLessThanOrEqual(1); + expect(maxReverse, `${scene}: upward scroll reversed`).toBeLessThanOrEqual(1); } } } @@ -307,29 +283,27 @@ if (process.versions.electron) { repetitions, viewport: '1200x900', conditions: - 'One Electron process, rotating modes, fresh DOM. Mount metrics include document navigation and readiness polling; not disk-cold startup or screen presentation.', + 'One Electron process, production layout, fresh DOM per trial. Mount metrics include document navigation and readiness polling; not disk-cold startup or screen presentation.', limits: 'Synthetic fixed-range production components, no Host. Three samples per configuration by default; p95 is the maximum. Geometry report is diagnostic, not a default-product correctness gate.', }, - scenes.flatMap(([scene]) => - modes.flatMap((mode) => { - const group = rows.filter((r) => r.scene === scene && r.mode === mode); - return [ - 'readyMs', - 'mountLayoutMs', - 'mountTaskMs', - 'maxTaskMs', - 'scrollMaxTaskMs', - 'layoutMs', - 'heightDrift', - 'maxReverse', - ].map((metric) => ({ - scenario: `${scene}/${mode}`, - metric, - ...summarize(group.map((r) => r[metric])), - })); - }), - ), + scenes.flatMap(([scene]) => { + const group = rows.filter((r) => r.scene === scene); + return [ + 'readyMs', + 'mountLayoutMs', + 'mountTaskMs', + 'maxTaskMs', + 'scrollMaxTaskMs', + 'layoutMs', + 'heightDrift', + 'maxReverse', + ].map((metric) => ({ + scenario: scene, + metric, + ...summarize(group.map((r) => r[metric])), + })); + }), ); console.log(`Report: ${output}`); } finally { diff --git a/scripts/perf/geometry-navigation.spec.ts b/scripts/perf/geometry-navigation.spec.ts index 96d2a132c3..83b606ff9e 100644 --- a/scripts/perf/geometry-navigation.spec.ts +++ b/scripts/perf/geometry-navigation.spec.ts @@ -22,10 +22,10 @@ import { withE2eWindow } from '../../apps/desktop/e2e/fixtures'; import { outputDir, report, summarize } from './report.mjs'; import path from 'node:path'; -test('layout ablation: document mount and older history', async () => { +test('production layout: document mount and older history', async () => { test.setTimeout(180_000); const samples: Array<{ - mode: string; + trial: number; action: string; ms: number; taskMs: number; @@ -49,20 +49,6 @@ test('layout ablation: document mount and older history', async () => { await cdp.send('Performance.enable'); const browser = await cdp.send('Browser.getVersion'); await page.addInitScript(() => { - const style = document.createElement('style'); - style.textContent = - sessionStorage.getItem('geometry-mode') === 'no-skip' - ? `.maka-transcript-turn, [data-maka-transcript-boundary], .astryx-codeblock [style*="contain-intrinsic-block-size"] { content-visibility:visible !important; contain:layout style paint !important; }` - : ''; - const append = () => document.documentElement.append(style); - if (document.documentElement) append(); - else - new MutationObserver((_, observer) => { - if (document.documentElement) { - append(); - observer.disconnect(); - } - }).observe(document, { childList: true }); const tasks: Array<{ start: number; duration: number }> = []; (window as any).__mountTasks = tasks; new PerformanceObserver((list) => @@ -84,8 +70,7 @@ test('layout ablation: document mount and older history', async () => { }; const metric = (m: { metrics: Array<{ name: string; value: number }> }, name: string) => m.metrics.find((v) => v.name === name)!.value; - for (const mode of ['baseline', 'no-skip', 'no-skip', 'baseline', 'baseline', 'no-skip']) { - await page.evaluate((mode) => sessionStorage.setItem('geometry-mode', mode), mode); + for (let trial = 0; trial < 3; trial++) { const measure = async (action: string, run: () => Promise) => { const before = await cdp.send('Performance.getMetrics'); const from = action === 'mount' ? 0 : await page.evaluate(() => performance.now()); @@ -101,7 +86,7 @@ test('layout ablation: document mount and older history', async () => { from, ); const sample = { - mode, + trial, action, ms, // Navigation resets CDP counters; only same-document actions use deltas. @@ -125,7 +110,7 @@ test('layout ablation: document mount and older history', async () => { await page.reload(); await ready(120); }); - if (mode === 'no-skip') { + { expect( await page .locator('[data-chat-scroll-container]') @@ -142,7 +127,12 @@ test('layout ablation: document mount and older history', async () => { .locator('.maka-prompt-rail-tick[data-prompt-turn-id="turn-prompt-rail-1"]') .click(); await ready(1); - await expect(page.locator('[data-transcript-gap="newer"]')).toBeVisible(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(0); + await expect( + page.getByRole('button', { + name: /^(滚动主对话到底部|Scroll main conversation to bottom)$/, + }), + ).toBeVisible(); }); await measure('latest', async () => { await page @@ -178,22 +168,20 @@ test('layout ablation: document mount and older history', async () => { samples, viewport: '1400x900', conditions: - 'One real Desktop + Host, six fresh renderer documents, baseline/no-skip/no-skip/baseline/baseline/no-skip. Existing 120-turn fixture; three measurements per mode/action.', + 'One real Desktop + Host, three fresh renderer documents using production layout. Existing 120-turn fixture; three measurements per action.', limits: 'Mount is renderer reload in a warm application, not disk-cold process startup. DOM readiness includes driver polling, fonts and two rendering frames, not screen presentation. Older/latest uses prompt-rail navigation, not wheel-triggered paging. Trace overhead included; no performance threshold imposed.', }, - ['baseline', 'no-skip'].flatMap((mode) => - ['mount', 'older', 'latest'].flatMap((action) => { - const group = samples.filter((s) => s.mode === mode && s.action === action); - return (['ms', 'taskMs', 'layoutMs', 'maxLongTaskMs', 'longTaskCount'] as const).map( - (metric) => ({ - scenario: `${action}/${mode}`, - metric, - ...summarize(group.map((s) => s[metric])), - }), - ); - }), - ), + ['mount', 'older', 'latest'].flatMap((action) => { + const group = samples.filter((s) => s.action === action); + return (['ms', 'taskMs', 'layoutMs', 'maxLongTaskMs', 'longTaskCount'] as const).map( + (metric) => ({ + scenario: action, + metric, + ...summarize(group.map((s) => s[metric])), + }), + ); + }), ); }, ); diff --git a/scripts/perf/geometry-results.md b/scripts/perf/geometry-results.md index d9ae2e9145..9f8c096e26 100644 --- a/scripts/perf/geometry-results.md +++ b/scripts/perf/geometry-results.md @@ -29,11 +29,13 @@ at commit. Messages and gap metadata form one published view; otherwise gap changes alone moved held height by 68px in the native probe. The old 1px input nudge and the render-skipping assertion were removed. -Ordinary CI runs the fixed-range driver with `GEOMETRY_MODE=baseline` and -`--assert-stable`. The former native diagnostic is now +Ordinary CI runs the production fixed-range driver with `--assert-stable`. +Historical ablation modes below describe the earlier experiments; the current +driver no longer injects CSS modes that are equivalent to production layout. +The former native diagnostic is now `apps/desktop/e2e/scroll-geometry.spec.ts`: held height/range, monotonic upward movement, release-frame anchor position, and subsequent navigation are strict -assertions. This implementation targets main, not the unmerged #5170 branch. +assertions. The implementation is integrated with #5170's bounded window. The post-change performance run must be compared using the report commit and environment; a successful job is not a statistical non-inferiority result. @@ -145,17 +147,15 @@ npm --workspace @maka/desktop run build-storybook node scripts/perf/geometry-ablation.mjs ``` -Explicit negative control (expected to fail on the measured source): +Current production geometry gate: ```sh -GEOMETRY_REPETITIONS=1 GEOMETRY_MODE=baseline GEOMETRY_SCENE=geometry-mixed-24-turns node scripts/perf/geometry-ablation.mjs --assert-stable +GEOMETRY_REPETITIONS=1 node scripts/perf/geometry-ablation.mjs --assert-stable ``` -Intervention with the same strict assertions: - -```sh -GEOMETRY_REPETITIONS=1 GEOMETRY_MODE=no-skip node scripts/perf/geometry-ablation.mjs --assert-stable -``` +The old baseline/no-skip negative control belongs to measurement commit +`817a5737d`; run that revision to reproduce the historical intervention. +Current code has no CSS mode switch. Compare different commits for performance. For the Host/window diagnostic, build Desktop first, then run from `apps/desktop` (the existing fixture resolves its app root from the working From 02954e8866d3cbd8deab8e6a3d9ab4a00c5d7b54 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 13:43:53 +0800 Subject: [PATCH 13/25] fix(desktop): retain stream settlement until transcript publication Generated-by: Codex --- .../__tests__/workhub-send-visibility.test.ts | 2 +- .../controller/use-workhub-controller.ts | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 439998817d..689312c150 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -261,6 +261,7 @@ test('WorkHub holds transcript and live handoff together until publication is ad { type: 'assistant', id: 'answer', turnId, text: 'Answer', ts: 2, modelId: 'fixture' }, ]; await act(() => h.publish(messages)); + await act(() => h.emit({ type: 'complete', id: 'done', turnId, ts: 3, stopReason: 'end_turn' })); await act(() => h.controller.streamingSettled('answer')); assert.equal(h.controller.transcript.messages.length, 0); assert.equal(h.controller.transientMessages.length, 1); @@ -268,7 +269,6 @@ test('WorkHub holds transcript and live handoff together until publication is ad await act(() => { held = false; idle(); }); assert.deepEqual(h.controller.transcript.messages, messages); assert.equal(h.controller.transientMessages.length, 0); - await act(() => h.controller.streamingSettled('answer')); assert.ok(!h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); detach(); h.latestRead.resolve(); diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 2795fdd52e..80f9c98bb7 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -65,6 +65,8 @@ export function useWorkHubController() { const [transcript, setTranscript] = useState(emptyTranscript); // Reconciliation reads the published view, never a source page held by input. const transcriptRef = useRef(emptyTranscript); + // Renderer completion is a one-shot signal; publication may arrive later. + const settledBeforePublication = useRef(new Set()); const [viewportNavigation] = useState(createTranscriptViewportNavigation); const [transientMessages, setTransientMessages] = useState([]); const [messageQueue, setMessageQueue] = useState<{ entries: import('@maka/core/events').MessageQueueEntryProjection[]; revision?: number }>({ entries: [] }); @@ -241,6 +243,7 @@ export function useWorkHubController() { useEffect(() => { setChoices([]); transcriptRef.current = emptyTranscript; + settledBeforePublication.current.clear(); setTranscript(emptyTranscript); setReadError(undefined); const attempt = pendingSend.current; @@ -347,9 +350,13 @@ export function useWorkHubController() { !snapshot.messages.some((message) => message.type === 'user' && (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), )); - setLiveTurns((previous) => - previous ? reconcileLiveTurnBuffer(previous, [...snapshot.messages]) : previous, - ); + const settled = snapshot.messages.filter((message) => + message.type === 'assistant' && settledBeforePublication.current.delete(message.id)); + setLiveTurns((previous) => { + let next = previous; + for (const message of settled) if (next) next = settleLiveTurnBufferStep(next, message.id); + return next ? reconcileLiveTurnBuffer(next, snapshot.messages) : next; + }); }); }, transcriptAbort.signal, readFailed); void opening @@ -559,7 +566,11 @@ export function useWorkHubController() { loadLatest: () => range.current?.loadLatest(), report, streamingSettled(messageId?: string) { - if (!messageId || !transcriptRef.current.messages.some((message) => message.id === messageId && message.type === 'assistant')) return; + if (!messageId || currentSessionId.current !== sessionId) return; + if (!transcriptRef.current.messages.some((message) => message.id === messageId && message.type === 'assistant')) { + settledBeforePublication.current.add(messageId); + return; + } setLiveTurns((previous) => { const next = previous ? settleLiveTurnBufferStep(previous, messageId) : undefined; return next ? reconcileLiveTurnBuffer(next, transcriptRef.current.messages) : next; From 5ca2d89dd2c53e62203e891f0e6c9db6ea5ce778 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 13:44:49 +0800 Subject: [PATCH 14/25] test(ui): focus geometry guards and trim experiment diagnostics Generated-by: Codex --- apps/desktop/e2e/scroll-geometry.spec.ts | 1 - .../e2e/transcript-scroll-cost.spec.ts | 25 ++- .../app-shell-first-send-cleanup.test.ts | 3 - scripts/perf/CI.md | 3 + scripts/perf/geometry-ablation.mjs | 36 +--- scripts/perf/geometry-results.md | 191 ++++-------------- 6 files changed, 61 insertions(+), 198 deletions(-) diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index 72b43fa8f1..e530a28142 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -200,7 +200,6 @@ test('native thumb keeps its geometry and releases history without moving the re 'upward native drag must not reverse', ).toBeLessThanOrEqual(1); const released = result.frames.filter((f: any) => !f.held && f.anchorTop !== undefined); - expect(released.length).toBeGreaterThan(2); expect( Math.max(...released.map((f: any) => Math.abs(f.anchorTop - reading.top))), 'reading anchor must survive every release frame', diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 8ff4bb8cd2..2a544661de 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -64,6 +64,7 @@ declare global { interface Window { __makaTranscriptDisplacement?: { boundaries: TranscriptBoundary[]; + input: { frames: number; heightDrift: number; rangeChanges: number; reverse: number }; isSettled(): boolean; stop(): void; }; @@ -153,6 +154,7 @@ async function observeDisplacement(page: Page): Promise { // publication. Arming from Playwright after wheel() returns races the same // rendering frames that publish the range and can miss every boundary. let recording = false; + let wheelSeen = false; const record = (on: boolean): void => { if (recording === on) return; recording = on; @@ -160,6 +162,7 @@ async function observeDisplacement(page: Page): Promise { settled = null; }; const onWheel = (event: Event): void => { + wheelSeen = true; const { deltaY } = event as WheelEvent; const remaining = deltaY < 0 ? scroller.scrollTop : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop; @@ -171,10 +174,12 @@ async function observeDisplacement(page: Page): Promise { scroller.addEventListener('scrollend', onScrollEnd, { capture: true }); const state: { boundaries: unknown[]; + input: { frames: number; heightDrift: number; rangeChanges: number; reverse: number }; isSettled(): boolean; stop(): void; } = { boundaries: [], + input: { frames: 0, heightDrift: 0, rangeChanges: 0, reverse: 0 }, isSettled: () => recording && settled === null, stop: () => { running = false; @@ -193,6 +198,12 @@ async function observeDisplacement(page: Page): Promise { if (!running) return; const current = read(); if (!recording) { + if (wheelSeen) { + state.input.frames++; + state.input.heightDrift = Math.max(state.input.heightDrift, Math.abs(current.scrollHeight - previous.scrollHeight)); + state.input.rangeChanges += Number(current.key !== previous.key); + state.input.reverse = Math.max(state.input.reverse, current.scrollTop - previous.scrollTop); + } settled = null; previous = current; requestAnimationFrame(tick); @@ -307,9 +318,8 @@ async function returnToLatest(page: Page): Promise { * range changes, whatever Turn the reader can still see must hold its viewport * position. * - * Each native scroll operation finishes before the next one starts. This - * isolates publication displacement from the reader's own movement; the - * continuous-wheel and held-thumb tests cover input that is still in flight. + * 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 @@ -336,9 +346,7 @@ test('Host history paging stays bounded, preserves the reader and returns to lat if (firstBefore === 'turn-prompt-rail-1') break; await expect .poll(async () => { - // One native scroll operation at a time: an older scrollend can arrive - // during a newer wheel animation and is not a quiet measurement point. - await wheel(page, cdp, { ticks: 1, deltaY: -1_200 }); + await wheel(page, cdp, { ticks: 12, deltaY: -120 }); await page.waitForFunction(() => window.__makaTranscriptDisplacement?.isSettled()); return turns.first().getAttribute('data-turn-id'); }) @@ -357,6 +365,11 @@ test('Host history paging stays bounded, preserves the reader and returns to lat expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); const boundaries = await displacement(page); + const input = await page.evaluate(() => window.__makaTranscriptDisplacement!.input); + expect(input.frames, 'the probe must observe consecutive wheel input').toBeGreaterThan(0); + expect(input.heightDrift, 'active wheel input must not change height').toBeLessThanOrEqual(1); + expect(input.rangeChanges, 'active wheel input must hold membership').toBe(0); + expect(input.reverse, 'upward wheel input must not reverse').toBeLessThanOrEqual(1); // The probe has to have seen the thing it measures: a run that paged nothing, // or one where every boundary replaced the range wholesale and carried no // Turn across, proves nothing about the reader. diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 039006af45..38cbd3f7ca 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -546,7 +546,6 @@ describe('composer first-send cleanup', () => { it('refresh waits for durable messages without bypassing range publication', async () => { const deps = createActionsDeps(); deps.activeIdRef.current = 'session'; - let directPublications = 0; let durable = false; const durableAnswer = { id: 'answer' }; let publishedAnswer = { id: 'answer' }; @@ -563,14 +562,12 @@ describe('composer first-send cleanup', () => { ...deps, transcriptRangeRef: { current: controller }, isMessagePublished: (message: unknown) => message === publishedAnswer, - setMessages: () => { directPublications++; }, }; const actions = createAppShellChatActions(dependencies); const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); assert.equal(durable, false); ready.resolve(); assert.equal(await refresh, false, 'durability cannot retire the live answer before publication'); - assert.equal(directPublications, 0, 'only the range subscription may publish source changes'); publishedAnswer = durableAnswer; assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true); }); diff --git a/scripts/perf/CI.md b/scripts/perf/CI.md index 8ae4410bd7..44da99444f 100644 --- a/scripts/perf/CI.md +++ b/scripts/perf/CI.md @@ -53,6 +53,9 @@ history uses the existing prompt rail, not wheel-triggered fill/trim. (mixed 24 turns, 45 tools, 1200-line code), with three repetitions each. Its mount CPU/layout counters are recorded before the first upward sweep, so deferred work cannot disappear from the comparison. +Scroll metrics cover only that cold upward sweep. Older reports included two +return sweeps and are not equivalent timing workloads. Unasserted per-step +anchors, LoAF and heap diagnostics are no longer collected by this driver. The script emits the shared report format and full per-frame geometry JSON. Both probes run sequentially in the existing frontend job and upload through its existing artifact step. No additional workflow or production switch exists. diff --git a/scripts/perf/geometry-ablation.mjs b/scripts/perf/geometry-ablation.mjs index 55d07cc876..e6f0b0ccf4 100644 --- a/scripts/perf/geometry-ablation.mjs +++ b/scripts/perf/geometry-ablation.mjs @@ -17,8 +17,7 @@ * under the License. */ -// Manual diagnostic, not a passing substitute for the future geometry CI -// gate. One Electron process, fresh DOM per trial, alternating configurations. +// Fixed-range geometry gate and performance samples, fresh DOM per trial. // Uses production ComposedShell stories; no Host, paging or streaming here. import { fileURLToPath } from 'node:url'; import { mkdir, writeFile } from 'node:fs/promises'; @@ -85,7 +84,6 @@ if (process.versions.electron) { const probe = (window.__geometry = { frames: [], tasks: [], - loaf: [], phase: 'mount', firstRootMs: null, }); @@ -96,15 +94,6 @@ if (process.versions.electron) { .map((e) => ({ start: e.startTime, duration: e.duration, phase: probe.phase })), ), ).observe({ type: 'longtask', buffered: true }); - if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) { - new PerformanceObserver((list) => - probe.loaf.push( - ...list - .getEntries() - .map((e) => ({ start: e.startTime, duration: e.duration, phase: probe.phase })), - ), - ).observe({ type: 'long-animation-frame', buffered: true }); - } const frame = () => { const root = document.querySelector('[data-chat-scroll-container]'); if (root) { @@ -162,7 +151,6 @@ if (process.versions.electron) { }); const beforeCpu = await cdp.send('Performance.getMetrics'); const box = await page.locator('[data-chat-scroll-container]').boundingBox(); - const steps = []; const sweep = async (phase, deltaY) => { await page.evaluate((phase) => { window.__geometry.phase = phase; @@ -170,15 +158,6 @@ if (process.versions.electron) { for (let tick = 0; tick < 350; tick++) { const before = await metrics(); if (deltaY < 0 ? before.t <= 1 : before.h - before.v - before.t <= 1) return; - // elementFromPoint selects only an already visible leaf. Never - // measure the offscreen descendants to find an anchor. - await page.evaluate( - ({ x, y }) => { - const el = document.elementFromPoint(x, y); - window.__anchor = { el, top: el?.getBoundingClientRect().top }; - }, - { x: box.x + box.width / 2, y: box.y + box.height / 2 }, - ); await cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: box.x + box.width / 2, @@ -188,18 +167,11 @@ if (process.versions.electron) { }); await paint(); const after = await metrics(); - const moved = await page.evaluate(() => { - const a = window.__anchor; - return a.el?.isConnected ? a.el.getBoundingClientRect().top - a.top : null; - }); - steps.push({ phase, tick, before, after, moved }); expect(after.count, 'fixed fixture membership changed').toBe(turns); } throw new Error(`${scene}/${phase} did not reach the edge within 350 wheel ticks`); }; await sweep('cold-up', -600); - await sweep('warm-down', 600); - await sweep('warm-up', -600); const afterCpu = await cdp.send('Performance.getMetrics'); await page.evaluate(() => { window.__geometry.phase = 'done'; @@ -237,8 +209,6 @@ if (process.versions.electron) { ), layoutMs: (metric(afterCpu, 'LayoutDuration') - metric(beforeCpu, 'LayoutDuration')) * 1000, - heap: await cdp.send('Runtime.getHeapUsage'), - steps, ...state, }; rows.push(row); @@ -251,7 +221,7 @@ if (process.versions.electron) { viewport: '1200x900', repetitions, conditions: - 'Same Electron; alternating fresh DOMs; fonts and Markdown ready; no offscreen box reads before traversal; real CDP wheel. Containment preserved; only skipping removed. Synthetic fixed-range production stories, no Host or paging.', + 'Same Electron; fresh DOM per trial; fonts and Markdown ready; no offscreen box reads; real CDP upward wheel. Synthetic fixed-range production stories, no Host or paging.', rows, }, null, @@ -285,7 +255,7 @@ if (process.versions.electron) { conditions: 'One Electron process, production layout, fresh DOM per trial. Mount metrics include document navigation and readiness polling; not disk-cold startup or screen presentation.', limits: - 'Synthetic fixed-range production components, no Host. Three samples per configuration by default; p95 is the maximum. Geometry report is diagnostic, not a default-product correctness gate.', + 'Synthetic fixed-range production components, no Host or paging. Three samples per scene by default; p95 is the maximum. --assert-stable gates cold upward height and monotonicity; timing measurements have no threshold. Scroll layout/task samples cover only the cold upward sweep.', }, scenes.flatMap(([scene]) => { const group = rows.filter((r) => r.scene === scene); diff --git a/scripts/perf/geometry-results.md b/scripts/perf/geometry-results.md index 9f8c096e26..d40389aef3 100644 --- a/scripts/perf/geometry-results.md +++ b/scripts/perf/geometry-results.md @@ -17,158 +17,39 @@ under the License. --> -# #5184 geometry ablation — 2026-09-11 - -## Implementation after the experiment - -The measurements below describe the pre-change source, not current product -behavior. The implementation now uses real layout for resident Turns, timeline -blocks and code chunks. The existing scroll authority defers range publication -during input, coalesces pending publication, and restores the reading Turn once -at commit. Messages and gap metadata form one published view; otherwise gap -changes alone moved held height by 68px in the native probe. The old 1px input -nudge and the render-skipping assertion were removed. - -Ordinary CI runs the production fixed-range driver with `--assert-stable`. -Historical ablation modes below describe the earlier experiments; the current -driver no longer injects CSS modes that are equivalent to production layout. -The former native diagnostic is now -`apps/desktop/e2e/scroll-geometry.spec.ts`: held height/range, monotonic upward -movement, release-frame anchor position, and subsequent navigation are strict -assertions. The implementation is integrated with #5170's bounded window. -The post-change performance run must be compared using the report commit and -environment; a successful job is not a statistical non-inferiority result. - -## Pre-change experiment - -This is a diagnostic result, not a production fix or a claim of a universal -performance bound. Production CSS and window policy were not modified. - -## Conditions - -- Fixed-range source: `a49ba7544c3bdd1ef648ec90c69b8f39f3e881c0`, with two new - deterministic ComposedShell stories and the diagnostic driver. -- Window comparison: the same main source and #5170 at - `770b2713d7110b145ccc367f1c24c2f0fc30ca04` (not an assertion about future heads). -- Apple M5 Pro, 64 GiB RAM, macOS arm64; Electron 43.4.1, - Chromium 150.0.7871.224. -- Fixed-range trial viewport 1200×900. Three scenarios × three modes × three - alternating repetitions in one Electron process, fresh DOM each time. Fonts - and Markdown ready before traversal; this is not disk-cold application startup. -- Real CDP wheel, 600px per tick; cold upward traversal, downward return and - second upward traversal. Root metrics sampled every rendering frame. No - offscreen descendant bounding-box reads to prepare the fixture. -- Ablations preserve `contain: layout style paint`; they remove skipping, not - all containment. No product toggle was added. - -## Fixed-range results - -Values below repeat identically across all three geometry trials. Height drift -is max minus min during the cold climb, not just final minus initial. Positive -upward reversal is the largest frame-to-frame increase in scrollTop. - -| Scenario | Mode | Height drift px | Upward reversal px | Ready median ms | Largest task, range ms | -| --- | --- | ---: | ---: | ---: | ---: | -| Mixed 24 Turns | Current | 20862 | 169.5 | 568 | 89–122 | -| Mixed 24 Turns | No Turn skipping | 21762 | 289.5 | 544 | 106–107 | -| Mixed 24 Turns | No skipping | 0 | 0 | 539 | 98–100 | -| 45-tool Turn | Current | 8002 | 25.5 | 520 | 71–75 | -| 45-tool Turn | No Turn skipping | 8002 | 25.5 | 515 | 74–80 | -| 45-tool Turn | No skipping | 0 | 0 | 499 | 76–80 | -| 1200-line code | Current | 2638 | 0 | 512 | 66–70 | -| 1200-line code | No Turn skipping | 2638 | 0 | 500 | 63–67 | -| 1200-line code | No skipping | 0 | 0 | 505 | 61–66 | - -All modes converge to identical final document heights: 28060, 9690 and 24413px -respectively, and identical DOM element counts (2878, 2313, 1576). Removing -skipping did not make the result stable by cutting out content. The no-skipping -probe also verifies zero remaining `content-visibility:auto` descendants. - -No >50ms task was observed during the measured scroll phases in any mode. -All modes had >50ms mount tasks. Ready timings include driver readiness polling -and loading, so small differences are not claimed as product speedups. Raw heap -samples contain uncollected objects and do not establish leak or memory bounds. -The separate strict green run observed a 144ms mount task; the table's maximum -is a sample result, not a performance upper bound. -Paint cost on weaker devices, very large highlighted code, asynchronous media, -font/width changes and ongoing streaming are not settled by these samples. - -## Real Host/native thumb drag - -120-Turn existing `chat-prompt-rail` fixture, 1000×700; baseline / no-skipping / -no-skipping / baseline in one app. A styled 14px **native** scrollbar is used to -avoid platform overlay ambiguity, with a gutter assertion and real CDP pointer -press/move/release. Every run must actually scroll >100px. Membership and H are -sampled while the pointer remains held, including 400ms without movement. - -| Source | Mode | Held height drift px, two trials | Distinct held ranges | -| --- | --- | --- | --- | -| main | Current | 134 / 118 | 4 / 5 | -| main | No skipping | 180 / 180 | 4 / 4 | -| #5170 | Current | 25268 / 25330 | 3 / 3 | -| #5170 | No skipping | 19363 / 16500 | 9 / 23 | - -This establishes that removing lazy estimates cannot make native thumb geometry -constant while the window changes membership. It does **not** quantify perceived -content jumps or prove an anchor-restoration implementation correct. A passing -window diagnostic means the measurement and input worked, not that the product -satisfies the strict geometry contract. - -## Architecture decision - -1. Do not build a Size Index, staging renderer, fixed shell or geometry snapshot - system on the strength of the original proposal. First implementation candidate - is ordinary layout inside the existing resident range, retaining only proven - containment needs. There is no measured requirement for a size manager yet. -2. Resolve all three skip sites: Turn, timeline block and Astryx CodeChunk. - Prefer the existing dependency/component seam over generated StyleX class - names. The experiment's broad override is not production code. -3. Keep window membership and scroll authority separate. #5170 still needs a - submission rule during held native scrollbar dragging. Strict H/monotonicity - for all incremental gestures also means deferring fill/trim, with edge waiting; - the experiment does not authorize relaxing that requirement. -4. Preserve a fixed-range constant-height/monotonicity regression and a distinct - window-drag contract. Do not classify a new window revision as an exemption - from a held-gesture guarantee. - -The diagnostic has an executable strict gate (`--assert-stable`): current mixed -content fails with 20862px drift; the no-skipping intervention passes all three -scenarios. This is not yet wired into ordinary product CI, because this stage -changes no production behavior. Integration of the actual fix must install the -default production-mode gate and replace overlapping weaker assertions. - -## Reproduce - -From repository root, after installing dependencies: - -```sh -npm --workspace @maka/core run build -npm --workspace @maka/desktop run build-storybook -node scripts/perf/geometry-ablation.mjs -``` - -Current production geometry gate: - -```sh -GEOMETRY_REPETITIONS=1 node scripts/perf/geometry-ablation.mjs --assert-stable -``` - -The old baseline/no-skip negative control belongs to measurement commit -`817a5737d`; run that revision to reproduce the historical intervention. -Current code has no CSS mode switch. Compare different commits for performance. - -For the Host/window diagnostic, build Desktop first, then run from -`apps/desktop` (the existing fixture resolves its app root from the working -directory): - -```sh -npm run build:with-deps -npx playwright test --config e2e/playwright.config.ts e2e/scroll-geometry.spec.ts -``` - -The original diagnostic and its JSON output belong to measurement commit -`817a5737d`; it has now been replaced by the ordinary E2E regression above. -The regression asserts held range/height, upward monotonicity, every sampled -release-frame anchor position and progress after release. Fixed -range reports default to repository `perf-results/geometry-ablation.json` and -can be directed with `GEOMETRY_OUTPUT`. Preserve reports before a subsequent run. +# Transcript geometry contract (#5184) + +Resident Turns, timeline blocks and code chunks use real layout. The existing +bounded window remains; there is no size index or warm-up traversal. +Publication holds messages and range metadata together during reader input. +The session owns pending publication across viewport detach; the scroll +authority admits it and restores the reading anchor at commit. + +## Verification + +- Ordinary CI runs `GEOMETRY_REPETITIONS=1 node scripts/perf/geometry-ablation.mjs --assert-stable`. + Three fixed-range production stories cover mixed Turns, tools and long code. + Cold upward scrolling must keep height drift and reverse motion within 1px. +- `apps/desktop/e2e/scroll-geometry.spec.ts` exercises the native scrollbar: + held height/membership, monotonic movement, release-frame anchor and progress. +- `apps/desktop/e2e/transcript-scroll-cost.spec.ts` covers bounded paging and + reading anchors across range changes with consecutive native wheel ticks. +- Performance commands and comparison limits are in [CI.md](CI.md). + Timing success alone does not establish statistical non-regression. + +The fixed-range probe waits for fonts and Markdown readiness. It does not +establish stability during cold Markdown admission, streaming or media resize. +Scroll timing now covers only the first upward sweep; older reports also +included a downward/upward return and must not be compared as equal workloads. + +## Historical evidence + +The original experimental modes and results are preserved in Git, rather than +maintained as a second description of current behavior: + +- [Pre-change measurement source](https://github.com/apache/maka/tree/817a5737d) +- [Full experimental record before this cleanup](https://github.com/apache/maka/blob/f9cd77cbee8d697a999d61ad716708169275bbe9/scripts/perf/geometry-results.md) + +That experiment separated lazy layout drift from window-publication drift. +Its machine-specific measurements are historical evidence, not current-head +performance results. From 212c9e43ed96251b08bfb6557a786156c2f4cff8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 14:30:22 +0800 Subject: [PATCH 15/25] fix(ui): hold publication until the last touch ends --- .../transcript-scroll-authority.test.ts | 24 +++++++++++++++++++ .../ui/src/transcript-scroll-authority.tsx | 8 ++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 9ec09f00b7..7b54c32ddc 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -47,6 +47,7 @@ interface FakeRoot { removeEventListener(type: string, listener: (event: unknown) => void): void; input(deltaY: number, modifiers?: { ctrlKey?: boolean; metaKey?: boolean }): void; grabScrollbar(): void; + touch(type: 'touchstart' | 'touchend' | 'touchcancel', count: number): void; end(): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; @@ -91,6 +92,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F emit('pointerdown', { button: 0, pointerType: 'mouse', pointerId: 1, target: proxy }); }, end() { emit('scrollend'); }, + touch(type, count) { emit(type, { touches: Array.from({ length: count }, () => ({ clientY: 100 })) }); }, grow(by) { root.scrollHeight += by; }, @@ -184,6 +186,28 @@ test('Ctrl and Meta wheel zoom preserve following without requesting history', ( }); }); +test('touch publication waits for the last contact to end or cancel', () => { + withObservers(() => { + for (const end of ['touchend', 'touchcancel'] as const) { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + publication.attachCommitScheduler('session', authority); + let commits = 0; + root.touch('touchstart', 1); + root.touch('touchstart', 2); + publication.commitRange('session', () => commits++); + assert.equal(commits, 0); + root.touch(end, 1); + assert.equal(commits, 0, 'remaining contact still holds publication'); + root.touch(end, 0); + assert.equal(commits, 1, 'last contact releases publication'); + detach(); + } + }); +}); + test('a held scrollbar coalesces range publication until release, including a stationary hold', () => { withObservers((_resize, frame) => { const root = fakeRoot(); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 0523d3fb80..1a7415a803 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -266,7 +266,13 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { } touchY = nextY; }; - const onTouchEnd = (): void => { touchY = undefined; touchHeld = false; onScrollEnd(); notifyIdle(); }; + const onTouchEnd = (event: TouchEvent): void => { + touchY = undefined; + if (event.touches.length > 0) return; + touchHeld = false; + onScrollEnd(); + notifyIdle(); + }; const onScroll = (): void => { awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; readingTurnId = readTurn(); From d052835c37cc2818501fc223e9ed5820f951dd26 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 15:21:09 +0800 Subject: [PATCH 16/25] fix(ui): distinguish answer accessibility scopes by conversation context --- apps/desktop/stories/app-shell.stories.tsx | 6 +++++- packages/ui/src/chat-turn.tsx | 13 +++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 3a6a3d1321..c9bc9dc100 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1104,7 +1104,7 @@ export const WideAssistantProse: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const answers = await canvas.findAllByRole('article', { name: 'Maka 的回答' }); + const answers = await canvas.findAllByRole('article', { name: /^Maka 的回答/ }); const answer = answers.at(-1); if (!answer) throw new Error('Wide assistant answer did not render'); const paragraph = await within(answer).findByRole('paragraph'); @@ -2633,6 +2633,10 @@ export const GeometryMixed24Turns: Story = { return [user(`geometry-u-${i}`, turnId, 50 - i, `检查第 ${i + 1} 组。`), assistant(`geometry-a-${i}`, turnId, 50 - i, prose + code)]; }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, + play: async ({ canvasElement }) => { + // Audit every code block after deferred Markdown has replaced its placeholder. + await waitFor(() => expect(within(canvasElement).getAllByRole('button', { name: '复制代码' })).toHaveLength(4)); + }, }; export const GeometryLongCode: Story = { diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 4fc4de0ceb..ca499d06c4 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -461,6 +461,11 @@ export const TurnView = memo(function TurnView(props: { const { turn } = props; const forwardBadges = props.lineageBadges?.filter((b) => b.direction === 'forward') ?? []; const reverseBadges = props.lineageBadges?.filter((b) => b.direction === 'reverse') ?? []; + const answerContext = accessibleActionContext( + turn.user?.text ?? finalAssistantReplyText(turn) ?? '', + turn.startedAt, + locale, + ); // A recorded conversational terminal turn owns presentation beyond its // timeline: failure/abort state and recovery actions must remain visible even // when the provider produced no assistant event. Inferred legacy turns and @@ -651,7 +656,7 @@ export const TurnView = memo(function TurnView(props: { return ( ) : undefined} - context={accessibleActionContext( - turn.user?.text ?? finalAssistantReplyText(turn) ?? '', - turn.startedAt, - locale, - )} + context={answerContext} onAction={ props.onFooterAction ? (actionId) => props.onFooterAction?.(turn.turnId, actionId) From 3eafd5deaac2b312fa8a084f9bee65218153b6b8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 15:47:35 +0800 Subject: [PATCH 17/25] fix(ui): preserve literal text in message accessibility labels --- .../src/__tests__/chat-turn-answer-identity.test.tsx | 11 ++++++++++- packages/ui/src/chat-turn.tsx | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index fb734db38a..73bd53f93e 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -24,7 +24,7 @@ import { afterEach, test } from 'node:test'; import { act, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { TurnView } from '../chat-turn.js'; +import { LocalizedChatMessage, TurnView } from '../chat-turn.js'; import { LocaleProvider } from '../locale-context.js'; import type { TurnTimelineItem, TurnViewModel } from '../materialize.js'; @@ -109,6 +109,15 @@ const RUNNING_TOOL: TurnTimelineItem = { items: [{ toolUseId: 'tool-1', toolName: 'read', status: 'running', args: {} }], }; +test('message accessibility labels preserve literal ICU syntax', async () => { + const { container, root } = domRoot(); + const label = "Maka's response · {value} it's literal"; + await act(() => { + root.render({null}); + }); + assert.equal(container.querySelector('article')?.getAttribute('aria-label'), label); +}); + test('renders an aborted turn outcome as an inline system status notice', async () => { const { container, root } = domRoot(); await renderTurn(root, { diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index ca499d06c4..85ed5d091d 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -83,7 +83,9 @@ export function LocalizedChatMessage({ accessibleLabel: string; }) { const overrides = useMemo( - () => ({ '@astryx.chatMessage.messageFrom': accessibleLabel }), + // This is already formatted text, not an ICU template. Quote from the + // first syntax character onward; ICU only opens a quote before syntax. + () => ({ '@astryx.chatMessage.messageFrom': accessibleLabel.replace(/'/g, "''").replace(/[{}<>].*$/s, "'$&'") }), [accessibleLabel], ); return ( From ac21650390044d1622168adb4896d48afaa2c6f8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 16:15:58 +0800 Subject: [PATCH 18/25] test(ui): allow deferred markdown to settle before accessibility audit --- apps/desktop/stories/app-shell.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index c9bc9dc100..8c3dee5520 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2635,7 +2635,10 @@ export const GeometryMixed24Turns: Story = { }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, play: async ({ canvasElement }) => { // Audit every code block after deferred Markdown has replaced its placeholder. - await waitFor(() => expect(within(canvasElement).getAllByRole('button', { name: '复制代码' })).toHaveLength(4)); + await waitFor( + () => expect(within(canvasElement).getAllByRole('button', { name: '复制代码' })).toHaveLength(4), + { timeout: 10_000 }, + ); }, }; From bd2176ebef4f7185acfd80f36d66624050d3e89b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 16:18:22 +0800 Subject: [PATCH 19/25] test(ui): remove geometry story readiness polling --- apps/desktop/stories/app-shell.stories.tsx | 7 ------- .../ui/src/__tests__/chat-turn-answer-identity.test.tsx | 4 ++++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 8c3dee5520..7c84fbd5d6 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2633,13 +2633,6 @@ export const GeometryMixed24Turns: Story = { return [user(`geometry-u-${i}`, turnId, 50 - i, `检查第 ${i + 1} 组。`), assistant(`geometry-a-${i}`, turnId, 50 - i, prose + code)]; }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, - play: async ({ canvasElement }) => { - // Audit every code block after deferred Markdown has replaced its placeholder. - await waitFor( - () => expect(within(canvasElement).getAllByRole('button', { name: '复制代码' })).toHaveLength(4), - { timeout: 10_000 }, - ); - }, }; export const GeometryLongCode: Story = { diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 73bd53f93e..ee62e1657e 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -303,6 +303,10 @@ test('uses human conversation context instead of raw ids in action names', async const actionNames = [...container.querySelectorAll('[aria-label]')] .map((element) => element.getAttribute('aria-label')) .filter((label): label is string => label !== null); + assert.match( + container.querySelector('.maka-assistant-answer')?.getAttribute('aria-label') ?? '', + /^Maka's response · Summarize the accessibility findings/, + ); assert.ok(actionNames.some((label) => label.startsWith( 'Copy message: Summarize the accessibility findings', ))); From e312d0d66ff6bc54f8907e15f251c236bdf9870e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 16:50:20 +0800 Subject: [PATCH 20/25] test(ui): remove redundant synthetic history anchor story --- apps/desktop/stories/app-shell.stories.tsx | 58 ---------------------- 1 file changed, 58 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 7c84fbd5d6..73fcb8a732 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2718,64 +2718,6 @@ export const OversizedLiveTurnHoldsAReadingAnchorOnColdScroll: Story = { render: () => , }; -export const EarlierHistoryLandsAboveTheReader: Story = { - render: () => , - play: async () => { - const root = tailScroller(); - await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); - - // Just short of the band that asks for more, so the active range has - // painted turns around the reader before the load starts. Landing straight - // on zero leaves no visible turn above the load boundary to anchor on. - scrollAsReader(root, loadBand() + 400); - await painted(6); - const before = firstResidentTurnId(); - const heightBefore = root.scrollHeight; - historyLoads.length = 0; - - // The move that asks for earlier history and the reading of where the - // reader is, in one task. - scrollAsReader(root, Math.min(300, root.scrollHeight - root.clientHeight)); - const rootTop = root.getBoundingClientRect().top; - const turn = [...root.querySelectorAll('[data-turn-id]')].find( - (candidate) => candidate.getBoundingClientRect().bottom > rootTop, - ); - if (!turn?.dataset.turnId) throw new Error('no turn is on screen'); - const anchor = { turnId: turn.dataset.turnId, top: Math.round(turn.getBoundingClientRect().top) }; - wheelUp(root); - - await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); - await painted(6); - - // The turns that arrived went above the reader, and the reader did not go - // with them. Asserting the element rather than a `scrollTop` delta is the - // point: a compensation computed from `scrollHeight` satisfies the delta - // while putting the reader somewhere else entirely. - // - // Budgeted against what arrived rather than in fixed pixels. A Turn carries - // `content-visibility: auto`, so one that lands off screen is anchored - // against its estimated height and settles a few pixels away from it; a - // reader who went with the history instead moves by the whole insert. - await waitFor(() => - expect( - tailScroller().scrollHeight - heightBefore, - JSON.stringify({ anchor, loads: historyLoads }), - ).toBeGreaterThan(400), - ); - - // Fixed once, after the arrival has settled. Recomputed on every retry it - // would grow along with the drift it is supposed to bound, so a late - // `content-visibility` resolution could admit a reading that was failing. - await painted(8); - const inserted = tailScroller().scrollHeight - heightBefore; - const budget = Math.max(4, inserted * 0.02); - expect( - Math.abs(turnTop(anchor.turnId) - anchor.top), - JSON.stringify({ anchor, inserted, budget, now: turnTop(anchor.turnId), ...tailMetrics() }), - ).toBeLessThanOrEqual(budget); - }, -}; - /** * First upward traversal of a deep fixed transcript: document height and * the reader's content position must remain stable without a warm-up pass. From df419c2497b3ccedebac5be2d146f3d4dc6b4078 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 17:38:29 +0800 Subject: [PATCH 21/25] fix(ui): converge input release through the idle notification gate --- .../transcript-scroll-authority.test.ts | 21 +++++++++++++++++++ .../ui/src/transcript-scroll-authority.tsx | 11 ++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 7b54c32ddc..988c3961eb 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -348,6 +348,27 @@ test('scrollbar defaults can land after pointerup, while an unmoved click retire }); }); +test('navigation during a held scrollbar still publishes on release or cancellation', async () => { + for (const event of ['pointerup', 'pointercancel']) { + const state = withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + publication.attachCommitScheduler('session', authority); + const commits: number[] = []; + root.grabScrollbar(); + publication.commitRange('session', () => commits.push(1)); + authority.releasePin(); + return { root, commits }; + }); + await Promise.resolve(); + assert.deepEqual(state.commits, [], 'navigation must preserve the physical hold'); + withObservers(() => state.root.ownerDocument.dispatchEvent(new Event(event))); + assert.deepEqual(state.commits, [1], 'release wakes the pending publication without another update'); + } +}); + test('explicit navigation cancels input provenance before positioning its target', () => { withObservers(() => { const root = fakeRoot(); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 1a7415a803..ea214de8df 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -118,6 +118,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const idleListeners = new Set<() => void>(); let pointer: number | undefined; let touchHeld = false; + const isInputActive = (): boolean => gesture !== undefined || pointer !== undefined || touchHeld; const commitRange = (commit: () => void): void => { const target = root; if (!target) { commit(); return; } @@ -140,7 +141,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { } }; const notifyIdle = (): void => { - if (gesture || pointer !== undefined || touchHeld) return; + if (isInputActive()) return; for (const listener of [...idleListeners]) listener(); }; let readingTurnId: string | undefined; @@ -181,9 +182,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { }; return { - isInputActive: () => gesture !== undefined || pointer !== undefined || touchHeld, + isInputActive, commitIfIdle(commit) { - if (gesture || pointer !== undefined || touchHeld) return false; + if (isInputActive()) return false; commitRange(commit); return true; }, @@ -271,7 +272,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { if (event.touches.length > 0) return; touchHeld = false; onScrollEnd(); - notifyIdle(); }; const onScroll = (): void => { awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; @@ -300,6 +300,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }; const onScrollEnd = (): void => { + // An explicit navigation may already have retired the gesture while + // a pointer or touch was held. Release still has to wake publication. + notifyIdle(); const ended = gesture; if (!ended) return; const top = ended.top; From c2eb6b63d2d87fa482757bc2093c15beae849370 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 17:38:36 +0800 Subject: [PATCH 22/25] fix(desktop): query current message publication from its owner --- apps/desktop/renderer-architecture.json | 4 +- .../app-shell-first-send-cleanup.test.ts | 41 +++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 3 +- .../use-app-shell-session-ui-state.ts | 1 + .../use-app-shell-session-workspace.ts | 12 ++++-- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2b74d8a26b..c11645d847 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -863,7 +863,7 @@ "react": 1 }, "importSpecifiers": 104, - "nonTriviaTokens": 13468 + "nonTriviaTokens": 13459 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -953,7 +953,7 @@ "react": 1 }, "importSpecifiers": 9, - "nonTriviaTokens": 465 + "nonTriviaTokens": 464 } }, "closure": { diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 38cbd3f7ca..8af560d34d 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -35,6 +35,10 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import { act, createElement } from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; @@ -572,6 +576,43 @@ describe('composer first-send cleanup', () => { assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true); }); + it('an in-flight refresh reads publication that commits after the call began', async () => { + const deps = createActionsDeps(); + deps.activeIdRef.current = 'session'; + const answer = { type: 'assistant', id: 'answer', text: 'done', ts: 1 } as StoredMessage; + const ready = deferred(); + const controller = { + ready: () => ready.promise, + store: { + snapshot: () => ({ sessionId: 'session', messages: [answer] }), + hasDurableMessage: () => true, + }, + } as unknown as DesktopTranscriptRangeController; + const { root } = installReactRenderer(); + let publication!: ReturnType['publication']; + function Probe(): null { + publication = useAppShellSessionUiState(deps.activeIdRef, () => {}).publication; + return null; + } + try { + act(() => root.render(createElement(Probe))); + const actions = createAppShellChatActions({ + ...deps, transcriptRangeRef: { current: controller }, + isMessagePublished: publication.isMessagePublished, + }); + const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); + act(() => { + publication.messagesRef.current = [answer]; + publication.setMessagesState([answer]); + }); + ready.resolve(); + assert.equal(await refresh, true, 'the original invocation must see the new publication'); + assert.equal(publication.isMessagePublished({ ...answer }), false, 'same id is not the published version'); + } finally { + cleanupFakeDom(); + } + }); + for (const initialized of [false, true]) { it(`does not navigate the previous Session controller (${initialized ? 'initialized' : 'opening'}) while sending`, async () => { const submissions: string[] = []; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index a3369358a5..db65839b2e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -327,6 +327,7 @@ function AppShellContent({ transcriptRangeRef, publishedTranscriptRange, publishTranscript, + isMessagePublished, messageLoadPending, setMessageLoadPending, sessionUiController, @@ -1471,7 +1472,7 @@ function AppShellContent({ removeTransientMessage, transcriptRangeRef, onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? Promise.resolve(true), - isMessagePublished: (message) => messages.includes(message), + isMessagePublished, setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index 49e8093e4e..dc22e799df 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -49,6 +49,7 @@ export function useAppShellSessionUiState ({ + isMessagePublished: (message: StoredMessage) => messagesRef.current.includes(message), setMessagesState(messages: StoredMessage[]) { setView({ messages, diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index c7799f8790..299ecd42a5 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -57,7 +57,10 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { }); const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); - const { messagesRef, transcriptRangeRef, setMessagesState } = publication; + const { + messagesRef, transcriptRangeRef, setMessagesState, + messages, publishedTranscriptRange, publishTranscript, isMessagePublished, + } = publication; const [transientMessages, setTransientMessages] = useState([]); const transientMessagesBySessionRef = useRef( new Map>(), @@ -102,9 +105,10 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { activeIdRef, bootstrapSelectionLease: bootstrapSelectionLeaseRef.current, ...actions, - messages: publication.messages, - publishedTranscriptRange: publication.publishedTranscriptRange, - publishTranscript: publication.publishTranscript, + messages, + publishedTranscriptRange, + publishTranscript, + isMessagePublished, transientMessages, transcriptRangeRef, messageLoadPending, From 447d68eb23fbed5dbdc7355f3f8707b1fc11c876 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 20:17:46 +0800 Subject: [PATCH 23/25] test(ui): align migrated motion story with stable geometry --- apps/desktop/e2e-budget.json | 2 +- apps/desktop/stories/app-shell.stories.tsx | 17 ++--------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 7375fb2012..030561efc4 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -67,7 +67,7 @@ }, "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, diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 73fcb8a732..f5fe9688dd 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -963,8 +963,7 @@ export const ManyTurns: Story = { ), }; -// The same transcript and fixture CSS, with enough resident Turns for native -// render skipping but no need to mount the 120-Turn catalog demonstration. +// Exercise transcript motion without mounting the 120-Turn catalog demonstration. export const TranscriptRenderCost: Story = { render: () => , play: async ({ canvasElement }) => { @@ -975,7 +974,6 @@ export const TranscriptRenderCost: Story = { await frame(); let transitions = 0; let animations = 0; - const skipped = new Set(); const turns = [...canvasElement.querySelectorAll('.maka-transcript-turn')]; expect(turns.length).toBeGreaterThan(0); for (const pseudo of [null, '::before', '::after']) { @@ -983,35 +981,24 @@ export const TranscriptRenderCost: Story = { expect(style.transitionProperty).toBe('none'); expect(style.animationName).toBe('none'); } - const visibility = (event: Event) => { - if (event.target !== event.currentTarget) return; - const turn = event.currentTarget as Element; - if ((event as Event & { skipped: boolean }).skipped) skipped.add(turn); - else skipped.delete(turn); - }; - for (const turn of turns) turn.addEventListener('contentvisibilityautostatechange', visibility); const transition = () => { transitions += 1; }; const animation = () => { animations += 1; }; canvasElement.addEventListener('transitionrun', transition, true); canvasElement.addEventListener('animationstart', animation, true); try { // No Host paging or wheel routing is under test: move the real Chromium - // scrollport to exercise the fixture CSS and browser render skipping. - // Two distant positions expose and skip resident Turns. Forty incremental - // paints added cost under CI contention without testing another contract. + // scrollport between two distant positions to exercise the fixture CSS. for (const direction of [-1, 1]) { scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: direction * 120, bubbles: true })); scroller.scrollTop = direction < 0 ? 0 : scroller.scrollHeight; await frame(); await frame(); } - await waitFor(() => expect(skipped.size).toBeGreaterThan(0)); expect(transitions).toBe(0); expect(animations).toBe(0); expect(canvasElement.getAnimations({ subtree: true }) .filter((animation) => animation.playState !== 'finished')).toHaveLength(0); } finally { - for (const turn of turns) turn.removeEventListener('contentvisibilityautostatechange', visibility); canvasElement.removeEventListener('transitionrun', transition, true); canvasElement.removeEventListener('animationstart', animation, true); } From f4bfcb300dff630491db01c461f579aaa4258a66 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 21:43:22 +0800 Subject: [PATCH 24/25] test(desktop): remove frame-dependent wheel sampling --- apps/desktop/e2e/transcript-scroll-cost.spec.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 2a544661de..45048ffe72 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -64,7 +64,6 @@ declare global { interface Window { __makaTranscriptDisplacement?: { boundaries: TranscriptBoundary[]; - input: { frames: number; heightDrift: number; rangeChanges: number; reverse: number }; isSettled(): boolean; stop(): void; }; @@ -154,7 +153,6 @@ async function observeDisplacement(page: Page): Promise { // publication. Arming from Playwright after wheel() returns races the same // rendering frames that publish the range and can miss every boundary. let recording = false; - let wheelSeen = false; const record = (on: boolean): void => { if (recording === on) return; recording = on; @@ -162,7 +160,6 @@ async function observeDisplacement(page: Page): Promise { settled = null; }; const onWheel = (event: Event): void => { - wheelSeen = true; const { deltaY } = event as WheelEvent; const remaining = deltaY < 0 ? scroller.scrollTop : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop; @@ -174,12 +171,10 @@ async function observeDisplacement(page: Page): Promise { scroller.addEventListener('scrollend', onScrollEnd, { capture: true }); const state: { boundaries: unknown[]; - input: { frames: number; heightDrift: number; rangeChanges: number; reverse: number }; isSettled(): boolean; stop(): void; } = { boundaries: [], - input: { frames: 0, heightDrift: 0, rangeChanges: 0, reverse: 0 }, isSettled: () => recording && settled === null, stop: () => { running = false; @@ -198,12 +193,6 @@ async function observeDisplacement(page: Page): Promise { if (!running) return; const current = read(); if (!recording) { - if (wheelSeen) { - state.input.frames++; - state.input.heightDrift = Math.max(state.input.heightDrift, Math.abs(current.scrollHeight - previous.scrollHeight)); - state.input.rangeChanges += Number(current.key !== previous.key); - state.input.reverse = Math.max(state.input.reverse, current.scrollTop - previous.scrollTop); - } settled = null; previous = current; requestAnimationFrame(tick); @@ -365,11 +354,6 @@ test('Host history paging stays bounded, preserves the reader and returns to lat expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); const boundaries = await displacement(page); - const input = await page.evaluate(() => window.__makaTranscriptDisplacement!.input); - expect(input.frames, 'the probe must observe consecutive wheel input').toBeGreaterThan(0); - expect(input.heightDrift, 'active wheel input must not change height').toBeLessThanOrEqual(1); - expect(input.rangeChanges, 'active wheel input must hold membership').toBe(0); - expect(input.reverse, 'upward wheel input must not reverse').toBeLessThanOrEqual(1); // The probe has to have seen the thing it measures: a run that paged nothing, // or one where every boundary replaced the range wholesale and carried no // Turn across, proves nothing about the reader. From 15b0e988d1cd0d11d36f0aef5092eb23e3e91fb9 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 22:09:54 +0800 Subject: [PATCH 25/25] test(ui): wait for restored model picker focus before keyboard input --- packages/ui/stories/model-picker.stories.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index 49b1e967d7..8d14574f3e 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -19,7 +19,7 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SessionSummary } from '@maka/core/session'; @@ -234,6 +234,10 @@ export const ExistingConversation: Story = { await expect(announcement).toBeEmptyDOMElement(); await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument(); + // Closing replaces the wheel with a new trigger and restores focus next frame. + await waitFor(() => expect(within(canvasElement).getByRole('button', { + name: /切换当前任务模型|Switch model for this task/, + })).toHaveFocus()); await userEvent.keyboard('{ArrowDown}'); await expect(announcement).toHaveTextContent(warning); },