From 73bb8176414ee4d93f85b32c1af0e78dcf3ca158 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 30 Aug 2026 19:11:11 +0800 Subject: [PATCH 01/19] perf(desktop): bound rendering within oversized turns Generated-by: OpenAI Codex --- apps/desktop/e2e/fixtures.ts | 12 +++ .../e2e/native-transcript-perf.spec.ts | 46 ++++++++++ .../desktop/e2e/oversized-turn-render.spec.ts | 56 +++++++++++++ apps/desktop/src/main/e2e-fixture.ts | 13 +++ .../src/main/e2e-fixture/scenarios-chat.ts | 83 +++++++++++++++++++ .../src/main/e2e-fixture/seed-helpers.ts | 1 + .../src/renderer/styles/chat-message.css | 23 +++++ packages/core/src/e2e-fixture.ts | 1 + 8 files changed, 235 insertions(+) create mode 100644 apps/desktop/e2e/oversized-turn-render.spec.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 2a725c5029..8af9366326 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -498,6 +498,7 @@ export const test = base.extend<{ railRenderWindow: Page; promptRailWindow: Page; partialHistoryWindow: Page; + oversizedTurnWindow: Page; promptRailMotionWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; @@ -615,6 +616,17 @@ export const test = base.extend<{ showWindow: true, }, use); }, + // One Turn larger than the transcript byte budget. Shown because the test + // reads Chromium's actual content-visibility state while crossing it. + oversizedTurnWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-turn-id="turn-oversized-fixture"]', + e2eFixtureScenario: 'chat-oversized-turn', + locale: 'zh', + showWindow: true, + }, use); + }, // The same transcript, scrolling the way the shipped app scrolls. Separate // from `promptRailWindow` because it is only the jump that needs a scroll // still in flight, and paying for one everywhere costs several seconds per diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts index 4c7eafb09b..de2773227f 100644 --- a/apps/desktop/e2e/native-transcript-perf.spec.ts +++ b/apps/desktop/e2e/native-transcript-perf.spec.ts @@ -323,6 +323,52 @@ test('warm native transcript scroll metrics', async ({ promptRailWindow: page }) console.log(`TRANSCRIPT_PERF ${JSON.stringify(result)}`); }); +test('oversized single Turn upward scroll metrics', async ({ + oversizedTurnWindow: page, +}) => { + test.skip(!PERF_ENABLED, 'manual same-build CDP oversized-Turn harness'); + test.setTimeout(90_000); + await page.setViewportSize({ width: 1_000, height: 700 }); + await expect(page.locator('[data-turn-id="turn-oversized-fixture"]')).toHaveCount(1); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + await prepareFrameRecorder(page); + await moveToTail(page); + const distance = await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + return root.scrollHeight - root.clientHeight; + }, SCROLLER); + const before = await performanceMetrics(cdp); + const frames = await scrollGesture(page, -distance, 480); + const after = await performanceMetrics(cdp); + const skippedSegments = await page.locator([ + '.maka-assistant-answer-content > .maka-chat-message-bubble-assistant', + '.maka-assistant-answer-content > .maka-processing-sequence', + '.maka-processing-sequence > *', + ].join(',')).evaluateAll((elements) => + (elements as HTMLElement[]).filter((element) => + !element.checkVisibility({ contentVisibilityAuto: true })).length, + ); + console.log(`OVERSIZED_TURN_PERF ${JSON.stringify({ + distance, + taskMs: metricDelta(before, after, 'TaskDuration') * 1_000, + layoutMs: metricDelta(before, after, 'LayoutDuration') * 1_000, + recalcStyleMs: metricDelta(before, after, 'RecalcStyleDuration') * 1_000, + frameP95Ms: percentile(frames.intervals, 0.95), + frameP99Ms: percentile(frames.intervals, 0.99), + frameMaxMs: Math.max(...frames.intervals), + loafOver50Ms: frames.loafDurations.filter((duration) => duration > 50).length, + loafMaxMs: Math.max(0, ...frames.loafDurations), + loafSupported: frames.loafSupported, + skippedSegments, + })}`); + expect( + frames.loafSupported, + 'Chromium does not support the long-animation-frame release metric', + ).toBe(true); +}); + test('600+ Turn repeated paging keeps the active range on a memory plateau', async ({ promptRailWindow: page, }) => { diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts new file mode 100644 index 0000000000..7b1cbfb119 --- /dev/null +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -0,0 +1,56 @@ +/* + * 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 { expect, test } from './fixtures'; + +const SEGMENT = [ + '.maka-assistant-answer-content > .maka-chat-message-bubble-assistant', + '.maka-assistant-answer-content > .maka-processing-sequence', + '.maka-processing-sequence > *', +].join(','); + +test('an oversized single Turn skips offscreen timeline blocks', async ({ + oversizedTurnWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 700 }); + const segments = page.locator(SEGMENT); + await expect(segments).not.toHaveCount(0); + expect(await segments.count()).toBeGreaterThan(80); + + const state = await segments.evaluateAll((elements) => { + const rows = elements as HTMLElement[]; + return { + automatic: rows.filter((element) => + getComputedStyle(element).contentVisibility === 'auto').length, + skipped: rows.filter((element) => + !element.checkVisibility({ contentVisibilityAuto: true })).length, + }; + }); + expect(state.automatic).toBe(await segments.count()); + expect(state.skipped).toBeGreaterThan(0); + + const first = segments.first(); + await first.evaluate((element) => element.scrollIntoView({ block: 'center' })); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); + expect(await first.evaluate((element) => + element.checkVisibility({ contentVisibilityAuto: true }), + )).toBe(true); +}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index dff4e6aab5..c901dfcc4f 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -34,6 +34,7 @@ import { LONG_SIDEBAR_PROJECT_ID, LONG_SIDEBAR_PROJECT_NAME, LONG_SIDEBAR_SESSION_PREFIX, + OVERSIZED_TURN_SESSION_ID, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, @@ -42,6 +43,8 @@ import { import { partialHistoryMessages, partialHistorySession, + oversizedTurnMessages, + oversizedTurnSession, promptRailMessages, promptRailSession, turnMessages, @@ -63,6 +66,7 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'turn-narrative-browser', 'chat-prompt-rail', 'chat-partial-history', + 'chat-oversized-turn', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -180,6 +184,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; case 'chat-partial-history': return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; + case 'chat-oversized-turn': + return { ...state, activeSessionId: OVERSIZED_TURN_SESSION_ID, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -238,6 +244,13 @@ export async function seedE2eFixture(input: { partialHistoryMessages(now), ); } + if (scenario === 'chat-oversized-turn') { + await writeSession( + input.workspaceRoot, + oversizedTurnSession(now), + oversizedTurnMessages(now), + ); + } if (scenario === 'sidebar-search-modal-open') { for (const seed of longSidebarSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index 760d9d5887..469ea1002d 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -20,6 +20,7 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { header, + OVERSIZED_TURN_SESSION_ID, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_PROMPT_COUNT, PROMPT_RAIL_SESSION_ID, @@ -191,3 +192,85 @@ export function partialHistoryMessages(now: number): StoredMessage[] { } return messages; } + +export function oversizedTurnSession(now: number): SessionHeader { + return header({ + id: OVERSIZED_TURN_SESSION_ID, + name: '单轮超长渲染边界示例', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 60_000, + }); +} + +/** + * One synthetic Turn that exceeds the Desktop transcript byte budget by + * itself. Alternating answers and tool evidence create many stable visual + * blocks inside the same Turn, reproducing the shape that whole-Turn + * containment cannot bound without carrying any real conversation data. + */ +export function oversizedTurnMessages(now: number): StoredMessage[] { + const turnId = 'turn-oversized-fixture'; + const messages: StoredMessage[] = [{ + type: 'user', + id: 'msg-oversized-user', + turnId, + ts: now - 10 * 60_000, + text: '检查一组独立的合成步骤,并逐项给出简短结果。', + }]; + const prose = [ + '这一段只包含确定性的合成文本,用于测量长对话的滚动渲染。', + '', + '- 已检查输入边界', + '- 已记录合成结果', + '- 下一步继续验证', + ].join('\n'); + const toolOutput = 'synthetic output line\n'.repeat(600); + for (let index = 1; index <= 48; index += 1) { + const ts = now - (49 - index) * 10_000; + messages.push({ + type: 'assistant', + id: `msg-oversized-assistant-${index}`, + turnId, + ts, + text: `### 合成步骤 ${index}\n\n${prose.repeat(12)}`, + modelId: 'glm-5.1', + }); + messages.push({ + type: 'tool_call', + id: `tool-oversized-${index}`, + turnId, + ts: ts + 1_000, + toolName: 'Bash', + displayName: `合成检查 ${index}`, + intent: `读取第 ${index} 组固定测试数据`, + args: { cmd: `fixture-check --step ${index}`, cwd: '/workspace/maka' }, + }); + messages.push({ + type: 'tool_result', + id: `tool-oversized-result-${index}`, + turnId, + ts: ts + 2_000, + toolUseId: `tool-oversized-${index}`, + isError: false, + durationMs: 100 + index, + content: { + kind: 'terminal', + cwd: '/workspace/maka', + cmd: `fixture-check --step ${index}`, + status: 'completed', + exitCode: 0, + output: { + mode: 'pipes', + stdout: toolOutput, + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + }); + } + return messages; +} diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index d38ecfaf78..3edea8bd25 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -35,6 +35,7 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; +export const OVERSIZED_TURN_SESSION_ID = 'e2e-fixture-oversized-turn'; /** Exceeds both the 64-tick rail and the bounded active transcript range. */ export const PROMPT_RAIL_PROMPT_COUNT = process.env.MAKA_TRANSCRIPT_STRESS === '1' ? 640 diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 9fa775b629..ad2dd691ed 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -123,6 +123,29 @@ 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. + + The first selector handles top-level answers, pure-reasoning runs, and a + whole Processing fold. The second keeps the fold useful after it becomes + visible by bounding each of its reasoning/tool children too. `auto` retains + the measured block size after first paint, which preserves native scroll + anchoring when a skipped block leaves and re-enters the viewport. */ +.maka-assistant-answer-content > :is( + .maka-chat-message-bubble-assistant, + .maka-processing-sequence, + .maka-deep-thinking, + .maka-tool-activity-card +), +.maka-processing-sequence > * { + content-visibility: auto; + contain-intrinsic-block-size: auto 96px; +} + /* Expanded activity headers stay reachable while their own detail is being read. Native sticky positioning keeps the header in the transcript flow, so it leaves naturally at the card boundary and does not disturb ChatLayout diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index b684297249..5e2d1823d9 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -28,6 +28,7 @@ export type E2eFixtureScenario = | 'turn-narrative-browser' | 'chat-prompt-rail' | 'chat-partial-history' + | 'chat-oversized-turn' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' From d393e7089fa2d3d391b0aded76676fb004989600 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 30 Aug 2026 23:12:19 +0800 Subject: [PATCH 02/19] fix(ui): release live tail before geometry changes --- .../desktop/e2e/oversized-turn-render.spec.ts | 66 +++++++++++++++++++ .../transcript-scroll-authority.test.ts | 51 ++++++++++++-- .../ui/src/transcript-scroll-authority.tsx | 66 +++++++++++++++---- packages/ui/src/use-chat-scroll.ts | 13 ++-- 4 files changed, 170 insertions(+), 26 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 7b1cbfb119..f6e9195d17 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -24,6 +24,15 @@ const SEGMENT = [ '.maka-assistant-answer-content > .maka-processing-sequence', '.maka-processing-sequence > *', ].join(','); +const SCROLLER = '[data-chat-scroll-container="true"]'; + +async function waitForPaintedFrames(page: import('@playwright/test').Page, frames = 4) { + await page.evaluate(async (count) => { + for (let frame = 0; frame < count; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + }, frames); +} test('an oversized single Turn skips offscreen timeline blocks', async ({ oversizedTurnWindow: page, @@ -54,3 +63,60 @@ test('an oversized single Turn skips offscreen timeline blocks', async ({ element.checkVisibility({ contentVisibilityAuto: true }), )).toBe(true); }); + +test('upward scrolling releases the live tail while skipped geometry materializes', async ({ + oversizedTurnWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 700 }); + const root = page.locator(SCROLLER); + await root.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await waitForPaintedFrames(page); + + const atTail = await root.evaluate((element) => + element.scrollHeight - element.scrollTop - element.clientHeight, + ); + expect(atTail).toBeLessThanOrEqual(4); + + // Force the ordering from the field report: the wheel begins materializing + // an intrinsic-size block before Chromium delivers the resulting scroll. + // Appending below the reader is deterministic synthetic growth; approaching + // the skipped timeline blocks above adds the real content-visibility change. + await root.evaluate((element) => { + const list = element.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + element.addEventListener('wheel', () => { + const growth = document.createElement('div'); + growth.dataset.oversizedTurnGrowth = 'true'; + growth.style.height = '600px'; + list.append(growth); + }, { capture: true, once: true }); + }); + + await root.hover(); + await page.mouse.wheel(0, -500); + await waitForPaintedFrames(page, 6); + + const released = await root.evaluate((element) => ({ + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + top: element.scrollTop, + })); + expect(released.distance).toBeGreaterThan(100); + + // A later delivery must preserve the released position too. Without the + // release, the scroll authority writes the latest tail on this resize. + await root.evaluate((element) => { + const growth = element.querySelector('[data-oversized-turn-growth]'); + if (!growth) throw new Error('the synthetic growth box is missing'); + growth.style.height = '900px'; + }); + await waitForPaintedFrames(page); + + const afterGrowth = await root.evaluate((element) => ({ + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + top: element.scrollTop, + })); + expect(afterGrowth.distance).toBeGreaterThan(released.distance); + expect(Math.abs(afterGrowth.top - released.top)).toBeLessThanOrEqual(4); +}); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index aaf66433a9..4e8aa6c04d 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -38,30 +38,42 @@ interface FakeRoot { clientHeight: number; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; - addEventListener(type: string, listener: () => void): void; - removeEventListener(type: string, listener: () => void): void; + addEventListener(type: string, listener: (event?: unknown) => void): void; + removeEventListener(type: string, listener: (event?: unknown) => void): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; + /** Dispatch an upward wheel whose default action can move this root. */ + emitUpwardWheel(): void; grow(by: number): void; /** Take height away from the viewport, as a resize or a taller dock does. */ shrinkViewport(by: number): void; } function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): FakeRoot { - const listeners = new Set<() => void>(); + const listeners = new Map void>>(); const root: FakeRoot = { scrollTop: 0, scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], addEventListener(type, listener) { - if (type === 'scroll') listeners.add(listener); + const bucket = listeners.get(type) ?? new Set(); + bucket.add(listener); + listeners.set(type, bucket); }, - removeEventListener(_type, listener) { - listeners.delete(listener); + removeEventListener(type, listener) { + listeners.get(type)?.delete(listener); }, emitScroll() { - for (const listener of [...listeners]) listener(); + for (const listener of [...(listeners.get('scroll') ?? [])]) listener(); + }, + emitUpwardWheel() { + const eventTarget = this; + const event = { + deltaY: -120, + composedPath: () => [eventTarget], + }; + for (const listener of [...(listeners.get('wheel') ?? [])]) listener(event); }, grow(by) { root.scrollHeight += by; @@ -164,6 +176,31 @@ test('a scroll this authority did not write is the reader, and releases the tail }); }); +test('reader movement releases the tail when content grows before the scroll event', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + // A skipped block materializes in the same rendering opportunity as the + // reader moves upward. The scroll event therefore observes both a changed + // offset and a changed scrollHeight; geometry movement must not erase the + // reader's intent merely because it arrived in the same delivery window. + root.emitUpwardWheel(); + root.scrollTop = 1_900; + root.grow(500); + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(authority.getSnapshot().awayFromTail, true); + + // The resize notification for that materialization arrives afterwards. + // Once released, it must not write the reader back to the new tail. + resize(); + assert.equal(root.scrollTop, 1_900); + }); +}); + test('returning to the tail re-pins, and following resumes', () => { withObservers((resize) => { const root = fakeRoot(); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index e2a20e5b0a..1630c69cad 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -33,12 +33,13 @@ * and "the reader is dragging" is also just don't touch it — so both of those * are the same instruction to this code: stay out of the way. * - * Being the only writer is what makes the state exact rather than guessed. It - * remembers the offset it wrote, so a scroll event that finds the scroller - * still on that offset is its own echo and any other offset is the reader — by - * construction, and with no dependence on when the event arrives. Astryx had to - * infer that from scroll direction, height deltas and wheel events, and every - * one of those signals has more than one cause. + * Being the only writer is what makes the ordinary state exact rather than + * guessed. It remembers the offset it wrote, so a scroll event that finds the + * scroller still on that offset is its own echo and any other stable-geometry + * offset is the reader — by construction, with no timing heuristic. The one + * ambiguous delivery is an upward wheel that materializes intrinsic geometry + * before its scroll event; a scoped wheel listener releases early only when no + * nested scroller can consume that input. */ import { @@ -86,6 +87,31 @@ export interface TranscriptScrollAuthority { getSnapshot(): TranscriptScrollSnapshot; } +/** + * A wheel dispatched below the transcript also crosses the transcript listener, + * even when a nested tool output or terminal will consume it. Only a nested + * scroller that can still move in the requested direction owns the gesture. + * At its boundary Chromium may chain the wheel to the transcript unless the + * nested surface explicitly contains overscroll. + */ +export function nestedScrollerConsumesWheel(event: WheelEvent, root: HTMLElement): boolean { + for (const target of event.composedPath()) { + if (target === root) break; + if (!(target instanceof HTMLElement)) continue; + const style = getComputedStyle(target); + const overflowY = style.overflowY; + if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue; + if (target.scrollHeight <= target.clientHeight) continue; + if (event.deltaY < 0 && target.scrollTop > 0) return true; + if ( + event.deltaY > 0 + && target.scrollTop + target.clientHeight < target.scrollHeight + ) return true; + if (['contain', 'none'].includes(style.overscrollBehaviorY)) return true; + } + return false; +} + export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { let root: HTMLElement | null = null; let pinned = true; @@ -135,6 +161,12 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }; + const releaseTail = (): void => { + pinned = false; + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); + }; + return { attach(next) { root = next; @@ -145,8 +177,8 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // put it on is the echo of that write, however late it arrives; any // other offset is the reader, exactly, and not by inference. Nested // scrollers (a tool output box, a terminal) never reach here at all: - // `scroll` does not bubble, and there is no `wheel` listener to catch - // instead. + // `scroll` does not bubble. The narrow wheel listener below separately + // rejects those nested paths before it can release this authority. if (lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) < 1) { lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; @@ -178,6 +210,19 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; target.addEventListener('scroll', onScroll, { passive: true }); + // Ordinarily the non-bubbling scroll event is the exact reader signal. + // One combined case needs intent one step earlier: content-visibility can + // replace intrinsic geometry before Chromium delivers the scroll caused + // by this wheel. `onScroll` must then classify the changed geometry as + // content movement, so release synchronously while the input ownership + // is still unambiguous. A wheel consumed by a nested scroller is not an + // outer-transcript gesture and leaves the pin untouched. + const onWheel = (event: WheelEvent): void => { + if (event.deltaY >= 0 || target.scrollTop <= 0) return; + if (nestedScrollerConsumesWheel(event, target)) return; + releaseTail(); + }; + target.addEventListener('wheel', onWheel, { passive: true }); // Everything that moves the tail without the reader asking, watched in // one place: the scroller's own box, because the tail also moves when the // viewport shrinks (a window resize, a composer that gains a line), and @@ -212,6 +257,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { childList.disconnect(); box.disconnect(); target.removeEventListener('scroll', onScroll); + target.removeEventListener('wheel', onWheel); lastWrittenTop = undefined; if (root === target) root = null; }; @@ -222,9 +268,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }, releasePin() { - pinned = false; - awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; - publish(); + releaseTail(); }, subscribeToReaderScroll(listener) { readerListeners.add(listener); diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 5e90444e2a..b9169e18b4 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -34,7 +34,10 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; -import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; +import { + nestedScrollerConsumesWheel, + useTranscriptScrollAuthority, +} from './transcript-scroll-authority.js'; export function useChatScroll(input: { scrollRef: RefObject; @@ -103,13 +106,7 @@ export function useChatScroll(input: { // is below. const onWheel = (event: WheelEvent): void => { if (event.deltaY >= 0 || !nearStart()) return; - for (const target of event.composedPath()) { - if (target === root) break; - if (!(target instanceof HTMLElement)) continue; - const overflowY = getComputedStyle(target).overflowY; - if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue; - if (target.scrollHeight > target.clientHeight && target.scrollTop > 0) return; - } + if (nestedScrollerConsumesWheel(event, root)) return; authority.releasePin(); requestEarlier(); }; From c0f66317387a01a3151d3925296ede26dbb0a13b Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 30 Aug 2026 23:35:31 +0800 Subject: [PATCH 03/19] test(desktop): enforce oversized turn performance gate --- apps/desktop/e2e/native-transcript-perf.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts index de2773227f..a8b312871d 100644 --- a/apps/desktop/e2e/native-transcript-perf.spec.ts +++ b/apps/desktop/e2e/native-transcript-perf.spec.ts @@ -350,7 +350,7 @@ test('oversized single Turn upward scroll metrics', async ({ (elements as HTMLElement[]).filter((element) => !element.checkVisibility({ contentVisibilityAuto: true })).length, ); - console.log(`OVERSIZED_TURN_PERF ${JSON.stringify({ + const result = { distance, taskMs: metricDelta(before, after, 'TaskDuration') * 1_000, layoutMs: metricDelta(before, after, 'LayoutDuration') * 1_000, @@ -362,11 +362,16 @@ test('oversized single Turn upward scroll metrics', async ({ loafMaxMs: Math.max(0, ...frames.loafDurations), loafSupported: frames.loafSupported, skippedSegments, - })}`); + }; + console.log(`OVERSIZED_TURN_PERF ${JSON.stringify(result)}`); expect( frames.loafSupported, 'Chromium does not support the long-animation-frame release metric', ).toBe(true); + expect( + result.loafOver50Ms, + `oversized-Turn upward scroll exceeded the 50 ms Long Animation Frame gate: ${JSON.stringify(result)}`, + ).toBe(0); }); test('600+ Turn repeated paging keeps the active range on a memory plateau', async ({ From 895cb38bff24aaab82fc4fcab27c0b45174dc8c7 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 31 Aug 2026 08:00:59 +0800 Subject: [PATCH 04/19] fix(ui): release live tail for focus navigation --- .../e2e/native-transcript-perf.spec.ts | 8 ++ .../desktop/e2e/oversized-turn-render.spec.ts | 86 +++++++++++++++++++ .../ui/src/transcript-scroll-authority.tsx | 21 ++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts index a8b312871d..6629edceb4 100644 --- a/apps/desktop/e2e/native-transcript-perf.spec.ts +++ b/apps/desktop/e2e/native-transcript-perf.spec.ts @@ -24,6 +24,10 @@ import { ensureSidebarExpanded, expect, test } from './fixtures'; const PERF_ENABLED = process.env.MAKA_TRANSCRIPT_PERF === '1'; const STRESS_ENABLED = process.env.MAKA_TRANSCRIPT_STRESS === '1'; +// Long-animation-frame delivery includes the native compositor. Xvfb's +// software/virtual display is useful for functional E2E, but is not comparable +// to the macOS arm64 environment in which this release threshold was measured. +const NATIVE_MACOS_ARM64_PERF_GATE = process.platform === 'darwin' && process.arch === 'arm64'; const SCROLLER = '[data-chat-scroll-container="true"]'; interface BrowserCounters { @@ -327,6 +331,10 @@ test('oversized single Turn upward scroll metrics', async ({ oversizedTurnWindow: page, }) => { test.skip(!PERF_ENABLED, 'manual same-build CDP oversized-Turn harness'); + test.skip( + !NATIVE_MACOS_ARM64_PERF_GATE, + '50 ms release gate is calibrated for native macOS arm64, not Linux/Xvfb', + ); test.setTimeout(90_000); await page.setViewportSize({ width: 1_000, height: 700 }); await expect(page.locator('[data-turn-id="turn-oversized-fixture"]')).toHaveCount(1); diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index f6e9195d17..9968df33c8 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -120,3 +120,89 @@ test('upward scrolling releases the live tail while skipped geometry materialize expect(afterGrowth.distance).toBeGreaterThan(released.distance); expect(Math.abs(afterGrowth.top - released.top)).toBeLessThanOrEqual(4); }); + +test('keyboard focus into a skipped card releases the live tail', async ({ + oversizedTurnWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 700 }); + const root = page.locator(SCROLLER); + await root.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await waitForPaintedFrames(page); + + const boundary = await root.evaluate((element, segmentSelector) => { + // Use the actual sequential focus order inside the transcript. The + // containment boundary is the tool-card row around Astryx's native button, + // so visibility must be asked of that row rather than its focused child. + const headers = [...element.querySelectorAll('[role="button"][tabindex="0"]')] + .map((header) => ({ header, row: header.closest(segmentSelector) })) + .filter((entry): entry is { header: HTMLElement; row: HTMLElement } => + entry.row != null, + ); + for (let index = 1; index < headers.length; index += 1) { + const previous = headers[index - 1]!; + const anchor = headers[index]!; + if ( + !previous.row.checkVisibility({ contentVisibilityAuto: true }) + && anchor.row.checkVisibility({ contentVisibilityAuto: true }) + ) { + previous.header.dataset.focusBoundaryTarget = 'true'; + anchor.header.dataset.focusBoundaryAnchor = 'true'; + anchor.header.focus({ preventScroll: true }); + return { found: true, headerCount: headers.length }; + } + } + return { found: false, headerCount: headers.length }; + }, SEGMENT); + expect(boundary.headerCount).toBeGreaterThan(5); + expect(boundary.found).toBe(true); + + // This is the normal sequential-navigation path, not a synthetic focus + // event. Shift+Tab enters the immediately preceding skipped activity card. + await page.keyboard.press('Shift+Tab'); + await waitForPaintedFrames(page, 6); + + const focused = await root.evaluate((element) => { + const active = document.activeElement as HTMLElement | null; + const rootRect = element.getBoundingClientRect(); + const activeRect = active?.getBoundingClientRect(); + return { + target: active?.dataset.focusBoundaryTarget === 'true', + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + top: element.scrollTop, + activeTop: activeRect?.top ?? Number.NaN, + withinViewport: activeRect != null + && activeRect.bottom > rootRect.top + && activeRect.top < rootRect.bottom, + }; + }); + expect(focused.target).toBe(true); + expect(focused.distance).toBeGreaterThan(100); + expect(focused.withinViewport).toBe(true); + + // A later content delivery resizes the observed transcript box. It must not + // re-pin and move the focused card out from under keyboard/AT navigation. + await root.evaluate((element) => { + const list = element.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + const growth = document.createElement('div'); + growth.style.height = '600px'; + list.append(growth); + }); + await waitForPaintedFrames(page); + + const afterGrowth = await root.evaluate((element) => { + const active = document.activeElement as HTMLElement | null; + return { + target: active?.dataset.focusBoundaryTarget === 'true', + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + top: element.scrollTop, + activeTop: active?.getBoundingClientRect().top ?? Number.NaN, + }; + }); + expect(afterGrowth.target).toBe(true); + expect(afterGrowth.distance).toBeGreaterThan(focused.distance); + expect(Math.abs(afterGrowth.top - focused.top)).toBeLessThanOrEqual(4); + expect(Math.abs(afterGrowth.activeTop - focused.activeTop)).toBeLessThanOrEqual(4); +}); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 1630c69cad..3724447def 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -39,7 +39,10 @@ * offset is the reader — by construction, with no timing heuristic. The one * ambiguous delivery is an upward wheel that materializes intrinsic geometry * before its scroll event; a scoped wheel listener releases early only when no - * nested scroller can consume that input. + * nested scroller can consume that input. Focus has the same ambiguity when + * keyboard or assistive navigation enters a skipped content-visibility subtree, + * so focus entering from outside the viewport releases before its geometry can + * move the tail. */ import { @@ -223,6 +226,21 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { releaseTail(); }; target.addEventListener('wheel', onWheel, { passive: true }); + // Sequential keyboard navigation and assistive technology can focus an + // element in a content-visibility:auto subtree that is currently skipped. + // Chromium materializes that subtree and scrolls it into view, but the + // resulting scroll/resize deliveries cannot distinguish that reader move + // from geometry growth. Release while focus ownership is unambiguous. + // Merely focusing a control already in the viewport keeps tail-following. + const onFocusIn = (event: FocusEvent): void => { + if (!pinned || !(event.target instanceof HTMLElement)) return; + const focusedRect = event.target.getBoundingClientRect(); + const rootRect = target.getBoundingClientRect(); + const outsideViewport = + focusedRect.bottom <= rootRect.top || focusedRect.top >= rootRect.bottom; + if (outsideViewport || distanceToTail() > PIN_THRESHOLD_PX) releaseTail(); + }; + target.addEventListener('focusin', onFocusIn); // Everything that moves the tail without the reader asking, watched in // one place: the scroller's own box, because the tail also moves when the // viewport shrinks (a window resize, a composer that gains a line), and @@ -258,6 +276,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { box.disconnect(); target.removeEventListener('scroll', onScroll); target.removeEventListener('wheel', onWheel); + target.removeEventListener('focusin', onFocusIn); lastWrittenTop = undefined; if (root === target) root = null; }; From d12b06162ecb657ed8b88fd129793f42e5d695a7 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 31 Aug 2026 13:55:01 +0800 Subject: [PATCH 05/19] fix(ui): preserve live tail for visible focus --- .../desktop/e2e/oversized-turn-render.spec.ts | 47 +++++++++++++++++++ .../ui/src/transcript-scroll-authority.tsx | 14 +++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 9968df33c8..22293a3d72 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -206,3 +206,50 @@ test('keyboard focus into a skipped card releases the live tail', async ({ expect(Math.abs(afterGrowth.top - focused.top)).toBeLessThanOrEqual(4); expect(Math.abs(afterGrowth.activeTop - focused.activeTop)).toBeLessThanOrEqual(4); }); + +test('visible transcript focus during pending growth keeps the live tail', async ({ + oversizedTurnWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 700 }); + const root = page.locator(SCROLLER); + await root.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await waitForPaintedFrames(page); + + await root.evaluate((element) => { + const list = element.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + const rootRect = element.getBoundingClientRect(); + const control = [...list.querySelectorAll('[role="button"][tabindex="0"]')] + .reverse() + .find((candidate) => { + const rect = candidate.getBoundingClientRect(); + return rect.bottom > rootRect.top && rect.top < rootRect.bottom; + }); + if (!control) throw new Error('no visible transcript control is available'); + + // Keep both mutations and focus in one task. ResizeObserver is therefore + // still pending when the visible control receives focus, which is the race + // where root distance must not be mistaken for reader movement. + const firstGrowth = document.createElement('div'); + firstGrowth.dataset.pendingFocusGrowth = 'true'; + firstGrowth.style.height = '600px'; + list.append(firstGrowth); + control.focus(); + const secondGrowth = document.createElement('div'); + secondGrowth.dataset.followUpFocusGrowth = 'true'; + secondGrowth.style.height = '300px'; + list.append(secondGrowth); + }); + await waitForPaintedFrames(page, 6); + + const result = await root.evaluate((element) => ({ + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + activeInTranscript: Boolean( + document.activeElement?.closest('.maka-chat-message-list'), + ), + })); + expect(result.activeInTranscript).toBe(true); + expect(result.distance).toBeLessThanOrEqual(4); +}); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 3724447def..ca40e908ba 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -231,14 +231,24 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // Chromium materializes that subtree and scrolls it into view, but the // resulting scroll/resize deliveries cannot distinguish that reader move // from geometry growth. Release while focus ownership is unambiguous. - // Merely focusing a control already in the viewport keeps tail-following. + // A visible composer is outside the transcript and must never release the + // tail. A visible transcript control also keeps following when geometry + // grew before ResizeObserver delivered it: in that window the root is + // still on the offset this authority wrote. Focus navigation that moved + // the reader changes that offset, or leaves the target outside the root + // viewport before Chromium brings it into view. const onFocusIn = (event: FocusEvent): void => { if (!pinned || !(event.target instanceof HTMLElement)) return; + if (!event.target.closest('.maka-chat-message-list')) return; const focusedRect = event.target.getBoundingClientRect(); const rootRect = target.getBoundingClientRect(); const outsideViewport = focusedRect.bottom <= rootRect.top || focusedRect.top >= rootRect.bottom; - if (outsideViewport || distanceToTail() > PIN_THRESHOLD_PX) releaseTail(); + const readerMoved = + lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) >= 1; + if (outsideViewport || (readerMoved && distanceToTail() > PIN_THRESHOLD_PX)) { + releaseTail(); + } }; target.addEventListener('focusin', onFocusIn); // Everything that moves the tail without the reader asking, watched in From 3d26257cac2e7cea96879c4085dc0d3eadb7f1b0 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 31 Aug 2026 14:19:31 +0800 Subject: [PATCH 06/19] test(desktop): target visible focus growth race --- .../desktop/e2e/oversized-turn-render.spec.ts | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 22293a3d72..f0602a7fff 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { expect, test } from './fixtures'; +import { COMPOSER_INPUT, expect, test } from './fixtures'; const SEGMENT = [ '.maka-assistant-answer-content > .maka-chat-message-bubble-assistant', @@ -207,7 +207,7 @@ test('keyboard focus into a skipped card releases the live tail', async ({ expect(Math.abs(afterGrowth.activeTop - focused.activeTop)).toBeLessThanOrEqual(4); }); -test('visible transcript focus during pending growth keeps the live tail', async ({ +test('visible composer focus during pending growth keeps the live tail', async ({ oversizedTurnWindow: page, }) => { await page.setViewportSize({ width: 900, height: 700 }); @@ -217,26 +217,42 @@ test('visible transcript focus during pending growth keeps the live tail', async }); await waitForPaintedFrames(page); - await root.evaluate((element) => { + const pending = await root.evaluate((element, composerSelector) => { const list = element.querySelector('.maka-chat-message-list'); if (!list) throw new Error('the transcript content box is missing'); + const composer = element.querySelector(composerSelector); + if (!composer) throw new Error('the visible composer is missing'); const rootRect = element.getBoundingClientRect(); - const control = [...list.querySelectorAll('[role="button"][tabindex="0"]')] - .reverse() - .find((candidate) => { - const rect = candidate.getBoundingClientRect(); - return rect.bottom > rootRect.top && rect.top < rootRect.bottom; - }); - if (!control) throw new Error('no visible transcript control is available'); - - // Keep both mutations and focus in one task. ResizeObserver is therefore + const composerRect = composer.getBoundingClientRect(); + + // Keep the first mutation and focus in one task. ResizeObserver is therefore // still pending when the visible control receives focus, which is the race // where root distance must not be mistaken for reader movement. const firstGrowth = document.createElement('div'); firstGrowth.dataset.pendingFocusGrowth = 'true'; firstGrowth.style.height = '600px'; list.append(firstGrowth); - control.focus(); + composer.focus(); + return { + focused: document.activeElement === composer, + visible: + composerRect.bottom > rootRect.top && composerRect.top < rootRect.bottom, + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + }; + }, COMPOSER_INPUT); + expect(pending.focused).toBe(true); + expect(pending.visible).toBe(true); + expect(pending.distance).toBeGreaterThan(100); + await waitForPaintedFrames(page, 6); + + const afterPendingGrowth = await root.evaluate((element) => + element.scrollHeight - element.scrollTop - element.clientHeight, + ); + expect(afterPendingGrowth).toBeLessThanOrEqual(4); + + await root.evaluate((element) => { + const list = element.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); const secondGrowth = document.createElement('div'); secondGrowth.dataset.followUpFocusGrowth = 'true'; secondGrowth.style.height = '300px'; @@ -244,12 +260,10 @@ test('visible transcript focus during pending growth keeps the live tail', async }); await waitForPaintedFrames(page, 6); - const result = await root.evaluate((element) => ({ + const result = await root.evaluate((element, composerSelector) => ({ distance: element.scrollHeight - element.scrollTop - element.clientHeight, - activeInTranscript: Boolean( - document.activeElement?.closest('.maka-chat-message-list'), - ), - })); - expect(result.activeInTranscript).toBe(true); + composerFocused: document.activeElement === element.querySelector(composerSelector), + }), COMPOSER_INPUT); + expect(result.composerFocused).toBe(true); expect(result.distance).toBeLessThanOrEqual(4); }); From af3cebfec1a8a9491ce460aa259c9964f59aa876 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 31 Aug 2026 14:32:01 +0800 Subject: [PATCH 07/19] fix(ui): classify focus before browser reveal --- .../transcript-scroll-authority.test.ts | 80 +++++++++++++++++++ .../ui/src/transcript-scroll-authority.tsx | 69 +++++++++++++--- 2 files changed, 136 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 4e8aa6c04d..d90a0dd740 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -44,6 +44,10 @@ interface FakeRoot { emitScroll(): void; /** Dispatch an upward wheel whose default action can move this root. */ emitUpwardWheel(): void; + emitFocusOut(next: unknown): void; + emitFocusIn(target: unknown): void; + contains(target: unknown): boolean; + getBoundingClientRect(): Pick; grow(by: number): void; /** Take height away from the viewport, as a resize or a taller dock does. */ shrinkViewport(by: number): void; @@ -75,6 +79,20 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F }; for (const listener of [...(listeners.get('wheel') ?? [])]) listener(event); }, + emitFocusOut(next) { + const event = { relatedTarget: next }; + for (const listener of [...(listeners.get('focusout') ?? [])]) listener(event); + }, + emitFocusIn(target) { + const event = { target }; + for (const listener of [...(listeners.get('focusin') ?? [])]) listener(event); + }, + contains(target) { + return target instanceof FakeFocusTarget; + }, + getBoundingClientRect() { + return { top: 0, bottom: root.clientHeight }; + }, grow(by) { root.scrollHeight += by; }, @@ -95,6 +113,29 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F }); } +class FakeFocusTarget { + constructor(private readonly rect: Pick) {} + + closest(): this { + return this; + } + + getBoundingClientRect(): Pick { + return this.rect; + } +} + +function withFakeHTMLElement(run: () => T): T { + const globals = globalThis as { HTMLElement?: unknown }; + const original = globals.HTMLElement; + globals.HTMLElement = FakeFocusTarget; + try { + return run(); + } finally { + globals.HTMLElement = original; + } +} + /** * The authority watches the scroller's box and its children's boxes, and keeps * that set current with a `MutationObserver`, so the suite owns both. `resize` @@ -201,6 +242,45 @@ test('reader movement releases the tail when content grows before the scroll eve }); }); +test('focus reveal of an already-visible control preserves pending tail growth', () => { + withFakeHTMLElement(() => withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + root.grow(500); + const visibleControl = new FakeFocusTarget({ top: 100, bottom: 140 }); + root.emitFocusOut(visibleControl); + // Chromium may reveal a partially visible target before focusin. That + // browser-owned movement is not evidence that the reader left the tail. + root.scrollTop = 2_300; + root.emitFocusIn(visibleControl); + assert.equal(authority.getSnapshot().pinned, true); + + resize(); + assert.equal(root.scrollTop, 2_900); + })); +}); + +test('focus entering a control outside the viewport releases the tail', () => { + withFakeHTMLElement(() => withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + + const offscreenControl = new FakeFocusTarget({ top: -200, bottom: -160 }); + root.emitFocusOut(offscreenControl); + root.scrollTop = 1_800; + root.emitFocusIn(offscreenControl); + assert.equal(authority.getSnapshot().pinned, false); + + root.grow(500); + resize(); + assert.equal(root.scrollTop, 1_800); + })); +}); + test('returning to the tail re-pins, and following resumes', () => { withObservers((resize) => { const root = fakeRoot(); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index ca40e908ba..19c2fce4bf 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -57,6 +57,14 @@ import { ChatLayoutScrollButton } from '@astryxdesign/core/Chat'; /** Astryx's own thresholds, so the affordance keeps the feel readers learnt. */ const PIN_THRESHOLD_PX = 10; const BUTTON_THRESHOLD_PX = 100; +const TRANSCRIPT_SELECTOR = '.maka-chat-message-list'; +const FOCUS_VISIBILITY_BOUNDARY = [ + '.maka-assistant-answer-content > .maka-chat-message-bubble-assistant', + '.maka-assistant-answer-content > .maka-processing-sequence', + '.maka-assistant-answer-content > .maka-deep-thinking', + '.maka-assistant-answer-content > .maka-tool-activity-card', + '.maka-processing-sequence > *', +].join(','); export interface TranscriptScrollSnapshot { /** Following the tail: growth writes `scrollTop`. */ @@ -231,22 +239,56 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // Chromium materializes that subtree and scrolls it into view, but the // resulting scroll/resize deliveries cannot distinguish that reader move // from geometry growth. Release while focus ownership is unambiguous. - // A visible composer is outside the transcript and must never release the - // tail. A visible transcript control also keeps following when geometry - // grew before ResizeObserver delivered it: in that window the root is - // still on the offset this authority wrote. Focus navigation that moved - // the reader changes that offset, or leaves the target outside the root - // viewport before Chromium brings it into view. - const onFocusIn = (event: FocusEvent): void => { - if (!pinned || !(event.target instanceof HTMLElement)) return; - if (!event.target.closest('.maka-chat-message-list')) return; - const focusedRect = event.target.getBoundingClientRect(); + // `focusin` is too late to decide whether focus moved the reader: Chromium + // has already scrolled a partially visible control fully into view by then. + // The preceding focusout names the incoming target in `relatedTarget`, so + // remember whether its containment boundary was outside the viewport before + // the browser reveals it. This also distinguishes pending geometry growth + // from reader movement without depending on ResizeObserver delivery order. + let incomingFocus: + | { readonly target: HTMLElement; readonly outsideViewport: boolean } + | undefined; + const isOutsideViewport = (element: HTMLElement): boolean => { + const boundary = element.closest(FOCUS_VISIBILITY_BOUNDARY) ?? element; + const focusedRect = boundary.getBoundingClientRect(); const rootRect = target.getBoundingClientRect(); - const outsideViewport = - focusedRect.bottom <= rootRect.top || focusedRect.top >= rootRect.bottom; + return focusedRect.bottom <= rootRect.top || focusedRect.top >= rootRect.bottom; + }; + const onFocusOut = (event: FocusEvent): void => { + const next = event.relatedTarget; + if (!(next instanceof HTMLElement) || !target.contains(next)) { + incomingFocus = undefined; + return; + } + incomingFocus = { + target: next, + outsideViewport: + next.closest(TRANSCRIPT_SELECTOR) !== null && isOutsideViewport(next), + }; + }; + // Listen on the document so focus entering from the sidebar or another + // surface is captured before it reaches this scroll root too. The fallback + // keeps the state-machine harness independent of a full DOM implementation. + const focusEventRoot = target.ownerDocument || target; + focusEventRoot.addEventListener('focusout', onFocusOut, true); + const onFocusIn = (event: FocusEvent): void => { + if (!(event.target instanceof HTMLElement)) { + incomingFocus = undefined; + return; + } + const before = incomingFocus?.target === event.target ? incomingFocus : undefined; + incomingFocus = undefined; + if (!pinned) return; + if (!event.target.closest(TRANSCRIPT_SELECTOR)) return; + if (before?.outsideViewport === false) return; + const outsideViewport = isOutsideViewport(event.target); const readerMoved = lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) >= 1; - if (outsideViewport || (readerMoved && distanceToTail() > PIN_THRESHOLD_PX)) { + if ( + before?.outsideViewport === true + || outsideViewport + || (before === undefined && readerMoved && distanceToTail() > PIN_THRESHOLD_PX) + ) { releaseTail(); } }; @@ -287,6 +329,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { target.removeEventListener('scroll', onScroll); target.removeEventListener('wheel', onWheel); target.removeEventListener('focusin', onFocusIn); + focusEventRoot.removeEventListener('focusout', onFocusOut, true); lastWrittenTop = undefined; if (root === target) root = null; }; From 24a20941efdcc31303ec41a309a9e9dc4511cc4c Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 31 Aug 2026 14:53:45 +0800 Subject: [PATCH 08/19] test(desktop): assert focused card position --- apps/desktop/e2e/oversized-turn-render.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index f0602a7fff..fffb7abad0 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -170,7 +170,6 @@ test('keyboard focus into a skipped card releases the live tail', async ({ return { target: active?.dataset.focusBoundaryTarget === 'true', distance: element.scrollHeight - element.scrollTop - element.clientHeight, - top: element.scrollTop, activeTop: activeRect?.top ?? Number.NaN, withinViewport: activeRect != null && activeRect.bottom > rootRect.top @@ -197,13 +196,14 @@ test('keyboard focus into a skipped card releases the live tail', async ({ return { target: active?.dataset.focusBoundaryTarget === 'true', distance: element.scrollHeight - element.scrollTop - element.clientHeight, - top: element.scrollTop, activeTop: active?.getBoundingClientRect().top ?? Number.NaN, }; }); expect(afterGrowth.target).toBe(true); expect(afterGrowth.distance).toBeGreaterThan(focused.distance); - expect(Math.abs(afterGrowth.top - focused.top)).toBeLessThanOrEqual(4); + // Skipped intrinsic geometry may change the internal scroll offset while + // native anchoring keeps the reader on the same pixels. The focused card's + // screen position is the user-facing invariant; raw scrollTop is not. expect(Math.abs(afterGrowth.activeTop - focused.activeTop)).toBeLessThanOrEqual(4); }); From efe3acb26eee99495b3839a34163c13cb6e60944 Mon Sep 17 00:00:00 2001 From: liugddx Date: Wed, 2 Sep 2026 10:16:37 +0800 Subject: [PATCH 09/19] perf(desktop): give tall transcript blocks a realistic intrinsic-size estimate A 96px contain-intrinsic-block-size placeholder on multi-line terminal/tool blocks forces a large scrollHeight correction as the reader approaches them, which the live-tail-release layer then compensates for. A closer first-paint estimate lets native overflow-anchor absorb most of the correction. Estimate only (auto px still grows to real size); no content-visibility change. Co-Authored-By: Claude Opus 4.8 --- .../src/renderer/styles/chat-message.css | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index ad2dd691ed..34c8621b2f 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -143,9 +143,35 @@ ), .maka-processing-sequence > * { 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; } +/* Reasoning runs and tool/activity cards (the latter wrap terminal + stdout/stderr output — see .maka-tool-output-chunk in tool-activity.tsx) are + routinely multi-line: dozens of lines, hundreds of px, not 96. A 96px + placeholder therefore guarantees a large scrollHeight correction the moment + the reader scrolls up to one of these skipped blocks, which the + live-tail-release layer then has to compensate for. Give them a + representative first-paint intrinsic-size ESTIMATE instead, so Chromium's + native overflow-anchor can absorb most of that correction. 320px is a + defensible middle-ground for a multi-line tool/reasoning block (roughly a + dozen-plus lines of output plus card chrome) — still an ESTIMATE, not a + clamp: `auto 320px` continues to grow to the real measured size after paint, + so it cannot regress correctness, only reduce the worst-case correction + magnitude versus the 96px placeholder. */ +.maka-assistant-answer-content > :is( + .maka-processing-sequence, + .maka-deep-thinking, + .maka-tool-activity-card +), +.maka-processing-sequence > * { + contain-intrinsic-block-size: auto 320px; +} + /* Expanded activity headers stay reachable while their own detail is being read. Native sticky positioning keeps the header in the transcript flow, so it leaves naturally at the card boundary and does not disturb ChatLayout From ffc698b42ee96da3bec6073e9fa2c88e1af6858f Mon Sep 17 00:00:00 2001 From: liugddx Date: Wed, 2 Sep 2026 20:05:09 +0800 Subject: [PATCH 10/19] test(desktop): tolerate transient fixture cleanup locks --- apps/desktop/e2e/fixtures.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index edba5a3072..31194acd02 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -484,7 +484,12 @@ async function withE2eWindow( try { if (app) await closeElectronApplication(app, 5_000); } finally { - await rm(userDataDir, { recursive: true, force: true }); + await rm(userDataDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); } } } From 214c4428b426a3b815b0142d59dea54d93a80bd6 Mon Sep 17 00:00:00 2001 From: liugddx Date: Wed, 2 Sep 2026 23:00:21 +0800 Subject: [PATCH 11/19] fix(desktop): reach the production PageUp path, drop dead scroll indirection - oversized-turn PageUp regression now focuses a visible tool-card header inside the list, exercising the `closest('.maka-chat-message-list')` branch users actually hit; the production scroller carries no tabindex, so the old `event.target === root` path was unreachable. - inline `nestedScrollerConsumesUpwardInput` at both wheel call sites and drop the `nestedScrollerConsumesWheel` forwarder that only wrapped `composedPath()`; drop the `|| target` fallback in `focusEventRoot` that only the removed fake-DOM tests needed. - return early from `onWheel`/`onKeyDown` once released, skipping the composed-path `getComputedStyle` walk in the exact gesture the perf gate measures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../desktop/e2e/oversized-turn-render.spec.ts | 19 +++++++++++++++++-- .../ui/src/transcript-scroll-authority.tsx | 15 ++++++++------- packages/ui/src/use-chat-scroll.ts | 4 ++-- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 5a50752a9c..9640302f07 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -173,8 +173,23 @@ test('PageUp releases the live tail while skipped geometry materializes', async await root.evaluate((element) => { const list = element.querySelector('.maka-chat-message-list'); if (!list) throw new Error('the transcript content box is missing'); - element.tabIndex = -1; - element.focus({ preventScroll: true }); + // The production scroller carries no tabindex, so `event.target === root` + // is unreachable there. A real PageUp is dispatched from a focused control + // inside the list and reaches the handler through + // `event.target.closest('.maka-chat-message-list')`. Focus a visible card + // header to exercise that path instead of focusing the scroller itself. + const rootRect = element.getBoundingClientRect(); + const header = [...element.querySelectorAll( + '.maka-tool-activity-card [role="button"][tabindex="0"]', + )].find((candidate) => { + const boundary = candidate.closest('[data-maka-transcript-boundary]'); + const rect = candidate.getBoundingClientRect(); + return boundary?.checkVisibility({ contentVisibilityAuto: true }) + && rect.top >= rootRect.top + && rect.bottom <= rootRect.bottom; + }); + if (!header) throw new Error('the visible tool-card header is missing'); + header.focus({ preventScroll: true }); element.addEventListener('keydown', (event) => { if (event.key !== 'PageUp') return; const growth = document.createElement('div'); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 1cd79d1e8e..126af8ad4e 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -96,7 +96,7 @@ export interface TranscriptScrollAuthority { * At its boundary Chromium may chain the wheel to the transcript unless the * nested surface explicitly contains overscroll. */ -function nestedScrollerConsumesUpwardInput( +export function nestedScrollerConsumesUpwardInput( path: readonly EventTarget[], root: HTMLElement, ): boolean { @@ -113,10 +113,6 @@ function nestedScrollerConsumesUpwardInput( return false; } -export function nestedScrollerConsumesWheel(event: WheelEvent, root: HTMLElement): boolean { - return nestedScrollerConsumesUpwardInput(event.composedPath(), root); -} - export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { let root: HTMLElement | null = null; let pinned = true; @@ -211,12 +207,17 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { lastClientHeight = target.clientHeight; target.addEventListener('scroll', onScroll, { passive: true }); const onWheel = (event: WheelEvent): void => { + // Already released: nothing here writes `scrollTop`, so skip the + // composed-path `getComputedStyle` walk — a forced style recalc on + // every wheel event of the exact gesture the perf gate measures. + if (!pinned) return; if (event.deltaY >= 0 || target.scrollTop <= 0) return; - if (nestedScrollerConsumesWheel(event, target)) return; + if (nestedScrollerConsumesUpwardInput(event.composedPath(), target)) return; releaseTail(); }; target.addEventListener('wheel', onWheel, { passive: true }); const onKeyDown = (event: KeyboardEvent): void => { + if (!pinned) return; const upward = ['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey); if (!upward || event.defaultPrevented || target.scrollTop <= 0) return; @@ -262,7 +263,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { next.closest(TRANSCRIPT_SELECTOR) !== null && isOutsideViewport(next), }; }; - const focusEventRoot = target.ownerDocument || target; + const focusEventRoot = target.ownerDocument; focusEventRoot.addEventListener('focusout', onFocusOut, true); const onFocusIn = (event: FocusEvent): void => { if (!(event.target instanceof HTMLElement)) { diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 5b196efa3a..7c62114351 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -35,7 +35,7 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { - nestedScrollerConsumesWheel, + nestedScrollerConsumesUpwardInput, useTranscriptScrollAuthority, } from './transcript-scroll-authority.js'; @@ -166,7 +166,7 @@ export function useChatScroll(input: { // is below. const onWheel = (event: WheelEvent): void => { if (event.deltaY >= 0 || !nearStart()) return; - if (nestedScrollerConsumesWheel(event, root)) return; + if (nestedScrollerConsumesUpwardInput(event.composedPath(), root)) return; authority.releasePin(); requestEarlier(); }; From 615bcce6f517bc1d54636cad416fd40b00e3e368 Mon Sep 17 00:00:00 2001 From: liugddx Date: Wed, 2 Sep 2026 23:14:33 +0800 Subject: [PATCH 12/19] test(desktop): pin the visible-Tab keep-tail contract under pending growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps focus between two visible tool-card headers in one task with growth appended while the ResizeObserver is still pending, so `focusout` carries a real in-transcript `relatedTarget` — the release path the blur-to-body fixtures could not reach. Asserts the pin survives and the tail follows growth back to the bottom. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../desktop/e2e/oversized-turn-render.spec.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 9640302f07..1efa25fc74 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -409,3 +409,73 @@ test('visible transcript focus during pending growth keeps the live tail', async expect(result.transcriptControlFocused).toBe(true); expect(result.distance).toBeLessThanOrEqual(4); }); + +test('Tab between two visible transcript controls under pending growth keeps the live tail', async ({ + oversizedTurnWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 700 }); + const root = page.locator(SCROLLER); + await root.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await waitForPaintedFrames(page); + + const pending = await root.evaluate((element) => { + const list = element.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + const rootRect = element.getBoundingClientRect(); + const visibleHeaders = [...element.querySelectorAll( + '.maka-tool-activity-card [role="button"][tabindex="0"]', + )].filter((candidate) => { + const boundary = candidate.closest('[data-maka-transcript-boundary]'); + const rect = candidate.getBoundingClientRect(); + return Boolean(boundary?.checkVisibility({ contentVisibilityAuto: true })) + && rect.top >= rootRect.top + && rect.bottom <= rootRect.bottom; + }); + if (visibleHeaders.length < 2) { + throw new Error('need two visible tool-card headers for the Tab regression'); + } + const from = visibleHeaders[0]!; + const to = visibleHeaders[visibleHeaders.length - 1]!; + to.dataset.tabTarget = 'true'; + + // One task: focus the first visible control, append growth, then move focus + // to the second visible control so `focusout` carries a real in-transcript + // `relatedTarget` while the ResizeObserver is still pending. A reader + // stepping between two controls they can both see has not left the tail, so + // the pin must survive — this is the release path the blur-to-body fixtures + // could not reach. + from.focus({ preventScroll: true }); + const growth = document.createElement('div'); + growth.dataset.tabPendingGrowth = 'true'; + growth.style.height = '600px'; + list.append(growth); + to.focus(); + + const toRect = to.getBoundingClientRect(); + return { + focusedTarget: document.activeElement === to, + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + toVisible: toRect.bottom > rootRect.top && toRect.top < rootRect.bottom, + }; + }); + expect(pending.focusedTarget).toBe(true); + expect(pending.toVisible).toBe(true); + expect(pending.distance).toBeGreaterThan(100); + await waitForPaintedFrames(page, 6); + await waitForStableScrollGeometry(page); + + const settled = await root.evaluate((element) => { + const active = document.activeElement as HTMLElement | null; + return { + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + transcriptControlFocused: + active?.matches('[data-maka-transcript-boundary] [role="button"]') ?? false, + stillOnTarget: active?.dataset.tabTarget === 'true', + }; + }); + expect(settled.transcriptControlFocused).toBe(true); + expect(settled.stillOnTarget).toBe(true); + expect(settled.distance).toBeLessThanOrEqual(4); +}); From 5f70b2c638789361d2c9d426be965974749cebc6 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 07:08:35 +0800 Subject: [PATCH 13/19] test(desktop): focus real controls in oversized turn regression --- apps/desktop/e2e/oversized-turn-render.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 1efa25fc74..493d4ff6ee 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -410,7 +410,7 @@ test('visible transcript focus during pending growth keeps the live tail', async expect(result.distance).toBeLessThanOrEqual(4); }); -test('Tab between two visible transcript controls under pending growth keeps the live tail', async ({ +test('Focus between two visible transcript controls under pending growth keeps the live tail', async ({ oversizedTurnWindow: page, }) => { await page.setViewportSize({ width: 900, height: 700 }); @@ -420,12 +420,17 @@ test('Tab between two visible transcript controls under pending growth keeps the }); await waitForPaintedFrames(page); + const groupHeader = root.locator('.maka-tool-activity-card [role="button"]').first(); + await expect(groupHeader).toBeVisible(); + await groupHeader.click(); + await waitForPaintedFrames(page); + const pending = await root.evaluate((element) => { const list = element.querySelector('.maka-chat-message-list'); if (!list) throw new Error('the transcript content box is missing'); const rootRect = element.getBoundingClientRect(); const visibleHeaders = [...element.querySelectorAll( - '.maka-tool-activity-card [role="button"][tabindex="0"]', + '.maka-tool-activity-card [role="button"]', )].filter((candidate) => { const boundary = candidate.closest('[data-maka-transcript-boundary]'); const rect = candidate.getBoundingClientRect(); @@ -434,10 +439,10 @@ test('Tab between two visible transcript controls under pending growth keeps the && rect.bottom <= rootRect.bottom; }); if (visibleHeaders.length < 2) { - throw new Error('need two visible tool-card headers for the Tab regression'); + throw new Error('need two visible tool-card controls for the focus regression'); } const from = visibleHeaders[0]!; - const to = visibleHeaders[visibleHeaders.length - 1]!; + const to = visibleHeaders[1]!; to.dataset.tabTarget = 'true'; // One task: focus the first visible control, append growth, then move focus From 1d4c637c7a04a02a5360458354c66303b61f086e Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 07:46:32 +0800 Subject: [PATCH 14/19] test(desktop): wait for tool group geometry to settle --- apps/desktop/e2e/oversized-turn-render.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 493d4ff6ee..562548fb89 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -424,6 +424,7 @@ test('Focus between two visible transcript controls under pending growth keeps t await expect(groupHeader).toBeVisible(); await groupHeader.click(); await waitForPaintedFrames(page); + await waitForStableScrollGeometry(page); const pending = await root.evaluate((element) => { const list = element.querySelector('.maka-chat-message-list'); From 0fa6a8d6f738a7f77d19c1d33ce8cbe8b4fd444f Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 08:47:28 +0800 Subject: [PATCH 15/19] test(desktop): target visible controls in expanded tool card --- apps/desktop/e2e/oversized-turn-render.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 562548fb89..6bc55ddbc3 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -420,7 +420,7 @@ test('Focus between two visible transcript controls under pending growth keeps t }); await waitForPaintedFrames(page); - const groupHeader = root.locator('.maka-tool-activity-card [role="button"]').first(); + const groupHeader = root.locator('.maka-tool-activity-card [role="button"]:visible').first(); await expect(groupHeader).toBeVisible(); await groupHeader.click(); await waitForPaintedFrames(page); @@ -431,7 +431,7 @@ test('Focus between two visible transcript controls under pending growth keeps t if (!list) throw new Error('the transcript content box is missing'); const rootRect = element.getBoundingClientRect(); const visibleHeaders = [...element.querySelectorAll( - '.maka-tool-activity-card [role="button"]', + '.maka-tool-activity-card [role="button"], .maka-tool-activity-card button', )].filter((candidate) => { const boundary = candidate.closest('[data-maka-transcript-boundary]'); const rect = candidate.getBoundingClientRect(); @@ -440,7 +440,7 @@ test('Focus between two visible transcript controls under pending growth keeps t && rect.bottom <= rootRect.bottom; }); if (visibleHeaders.length < 2) { - throw new Error('need two visible tool-card controls for the focus regression'); + throw new Error('need two visible controls in the expanded tool card'); } const from = visibleHeaders[0]!; const to = visibleHeaders[1]!; @@ -477,7 +477,7 @@ test('Focus between two visible transcript controls under pending growth keeps t return { distance: element.scrollHeight - element.scrollTop - element.clientHeight, transcriptControlFocused: - active?.matches('[data-maka-transcript-boundary] [role="button"]') ?? false, + active?.closest('[data-maka-transcript-boundary]') !== null, stillOnTarget: active?.dataset.tabTarget === 'true', }; }); From 49f8d5ca8b08f20d3648df54702accfa7dee6b12 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 09:19:38 +0800 Subject: [PATCH 16/19] test(desktop): choose actually visible transcript control --- .../desktop/e2e/oversized-turn-render.spec.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 6bc55ddbc3..3808540520 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -420,7 +420,25 @@ test('Focus between two visible transcript controls under pending growth keeps t }); await waitForPaintedFrames(page); - const groupHeader = root.locator('.maka-tool-activity-card [role="button"]:visible').first(); + const visibleGroupHeader = await root.evaluate((element) => { + const rootRect = element.getBoundingClientRect(); + const header = [...element.querySelectorAll( + '.maka-tool-activity-card [role="button"][tabindex="0"]', + )].find((candidate) => { + const boundary = candidate.closest('[data-maka-transcript-boundary]'); + const rect = candidate.getBoundingClientRect(); + return Boolean(boundary?.checkVisibility({ contentVisibilityAuto: true })) + && rect.top >= rootRect.top + && rect.bottom <= rootRect.bottom; + }); + if (!header) throw new Error('the visible tool-card header is missing'); + header.dataset.e2eVisibleGroupHeader = 'true'; + return true; + }); + expect(visibleGroupHeader).toBe(true); + const groupHeader = root.locator( + '.maka-tool-activity-card [data-e2e-visible-group-header="true"]', + ); await expect(groupHeader).toBeVisible(); await groupHeader.click(); await waitForPaintedFrames(page); From 496f2014b288304115653972535f36dac80f12e5 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 15:59:15 +0800 Subject: [PATCH 17/19] fix(ui): release the transcript pin from position, drop input enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the oversized-turn fix to the guard change #4269 actually needs, per maintainer review. The regression is that a `moved` scroll event — geometry changed since the last one — refused to touch the pin, so under a patch storm the position-based release never ran and the ResizeObserver re-pinned the reader to the tail every frame ("grabs the scrollbar"). Leaving the tail is monotonic and readable from position alone, so a moved event now releases the pin when distance > PIN_THRESHOLD_PX; only re-pinning still needs the stable-geometry path or an explicit pinToTail(). This drops the wheel/keydown/pointerdown/focusin/focusout enumeration the previous shape added (which could not close touch-drag or macOS overlay scrollbars, and left two wheel listeners on the scroller), and restores the by-construction header. Keep the sub-turn content-visibility containment and its boundary markers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../desktop/e2e/oversized-turn-render.spec.ts | 258 ------------------ .../transcript-scroll-authority.test.ts | 112 +++----- .../ui/src/transcript-scroll-authority.tsx | 165 ++--------- packages/ui/src/use-chat-scroll.ts | 13 +- 4 files changed, 83 insertions(+), 465 deletions(-) diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 3808540520..60f7ed4b1b 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -245,261 +245,3 @@ test('PageUp releases the live tail while skipped geometry materializes', async expect(afterGrowth.distance).toBeGreaterThan(released.distance); expect(afterGrowth.anchorVisible).toBe(true); }); - -test('keyboard focus into a skipped card releases the live tail', async ({ - oversizedTurnWindow: page, -}) => { - await page.setViewportSize({ width: 900, height: 700 }); - const root = page.locator(SCROLLER); - await root.evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - await waitForPaintedFrames(page); - - const boundary = await root.evaluate((element, segmentSelector) => { - // Use the actual sequential focus order inside the transcript. The - // containment boundary is the tool-card row around Astryx's native button, - // so visibility must be asked of that row rather than its focused child. - const headers = [...element.querySelectorAll( - '.maka-tool-activity-card [role="button"][tabindex="0"]', - )] - .map((header) => ({ header, row: header.closest(segmentSelector) })) - .filter((entry): entry is { header: HTMLElement; row: HTMLElement } => - entry.row != null, - ); - for (let index = 1; index < headers.length; index += 1) { - const previous = headers[index - 1]!; - const anchor = headers[index]!; - if ( - !previous.row.checkVisibility({ contentVisibilityAuto: true }) - && anchor.row.checkVisibility({ contentVisibilityAuto: true }) - ) { - previous.header.dataset.focusBoundaryTarget = 'true'; - anchor.header.dataset.focusBoundaryAnchor = 'true'; - anchor.header.focus({ preventScroll: true }); - return { found: true, headerCount: headers.length }; - } - } - return { found: false, headerCount: headers.length }; - }, SEGMENT); - expect(boundary.headerCount).toBeGreaterThan(5); - expect(boundary.found).toBe(true); - - // This is the normal sequential-navigation path, not a synthetic focus - // event. Shift+Tab enters the immediately preceding skipped activity card. - await page.keyboard.press('Shift+Tab'); - await waitForPaintedFrames(page, 6); - - const focused = await root.evaluate((element) => { - const active = document.activeElement as HTMLElement | null; - const rootRect = element.getBoundingClientRect(); - const activeRect = active?.getBoundingClientRect(); - return { - target: active?.dataset.focusBoundaryTarget === 'true', - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - activeTop: activeRect?.top ?? Number.NaN, - withinViewport: activeRect != null - && activeRect.bottom > rootRect.top - && activeRect.top < rootRect.bottom, - }; - }); - expect(focused.target).toBe(true); - expect(focused.distance).toBeGreaterThan(100); - expect(focused.withinViewport).toBe(true); - - // A later content delivery resizes the observed transcript box. It must not - // re-pin and move the focused card out from under keyboard/AT navigation. - await root.evaluate((element) => { - const list = element.querySelector('.maka-chat-message-list'); - if (!list) throw new Error('the transcript content box is missing'); - const growth = document.createElement('div'); - growth.style.height = '600px'; - list.append(growth); - }); - await waitForPaintedFrames(page); - - const afterGrowth = await root.evaluate((element) => { - const active = document.activeElement as HTMLElement | null; - const rootRect = element.getBoundingClientRect(); - const activeRect = active?.getBoundingClientRect(); - return { - target: active?.dataset.focusBoundaryTarget === 'true', - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - withinViewport: activeRect != null - && activeRect.bottom > rootRect.top - && activeRect.top < rootRect.bottom, - }; - }); - expect(afterGrowth.target).toBe(true); - expect(afterGrowth.distance).toBeGreaterThan(focused.distance); - // Intrinsic-size correction may move the row by one placeholder while native - // anchoring settles. The accessibility contract is that focus remains on the - // same card and later growth cannot push it out of the viewport. - expect(afterGrowth.withinViewport).toBe(true); -}); - -test('visible transcript focus during pending growth keeps the live tail', async ({ - oversizedTurnWindow: page, -}) => { - await page.setViewportSize({ width: 900, height: 700 }); - const root = page.locator(SCROLLER); - await root.evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - await waitForPaintedFrames(page); - - const pending = await root.evaluate((element) => { - const list = element.querySelector('.maka-chat-message-list'); - if (!list) throw new Error('the transcript content box is missing'); - const rootRect = element.getBoundingClientRect(); - const header = [...element.querySelectorAll( - '.maka-tool-activity-card [role="button"][tabindex="0"]', - )].find((candidate) => { - const boundary = candidate.closest('[data-maka-transcript-boundary]'); - const rect = candidate.getBoundingClientRect(); - return boundary?.checkVisibility({ contentVisibilityAuto: true }) - && rect.top >= rootRect.top - && rect.bottom <= rootRect.bottom; - }); - if (!header) throw new Error('the visible tool-card header is missing'); - - // Keep the first mutation and focus in one task. ResizeObserver is therefore - // still pending when the visible control receives focus, which is the race - // where root distance must not be mistaken for reader movement. - header.focus({ preventScroll: true }); - header.blur(); - const firstGrowth = document.createElement('div'); - firstGrowth.dataset.pendingFocusGrowth = 'true'; - firstGrowth.style.height = '600px'; - list.append(firstGrowth); - header.focus(); - const headerRect = header.getBoundingClientRect(); - return { - focused: document.activeElement === header, - visible: - headerRect.bottom > rootRect.top && headerRect.top < rootRect.bottom, - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - }; - }); - expect(pending.focused).toBe(true); - expect(pending.visible).toBe(true); - expect(pending.distance).toBeGreaterThan(100); - await waitForPaintedFrames(page, 6); - - const afterPendingGrowth = await root.evaluate((element) => - element.scrollHeight - element.scrollTop - element.clientHeight, - ); - expect(afterPendingGrowth).toBeLessThanOrEqual(4); - - await root.evaluate((element) => { - const list = element.querySelector('.maka-chat-message-list'); - if (!list) throw new Error('the transcript content box is missing'); - const secondGrowth = document.createElement('div'); - secondGrowth.dataset.followUpFocusGrowth = 'true'; - secondGrowth.style.height = '300px'; - list.append(secondGrowth); - }); - await waitForPaintedFrames(page, 6); - - const result = await root.evaluate((element) => ({ - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - transcriptControlFocused: - document.activeElement?.matches('[data-maka-transcript-boundary] [role="button"]') ?? false, - })); - expect(result.transcriptControlFocused).toBe(true); - expect(result.distance).toBeLessThanOrEqual(4); -}); - -test('Focus between two visible transcript controls under pending growth keeps the live tail', async ({ - oversizedTurnWindow: page, -}) => { - await page.setViewportSize({ width: 900, height: 700 }); - const root = page.locator(SCROLLER); - await root.evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - await waitForPaintedFrames(page); - - const visibleGroupHeader = await root.evaluate((element) => { - const rootRect = element.getBoundingClientRect(); - const header = [...element.querySelectorAll( - '.maka-tool-activity-card [role="button"][tabindex="0"]', - )].find((candidate) => { - const boundary = candidate.closest('[data-maka-transcript-boundary]'); - const rect = candidate.getBoundingClientRect(); - return Boolean(boundary?.checkVisibility({ contentVisibilityAuto: true })) - && rect.top >= rootRect.top - && rect.bottom <= rootRect.bottom; - }); - if (!header) throw new Error('the visible tool-card header is missing'); - header.dataset.e2eVisibleGroupHeader = 'true'; - return true; - }); - expect(visibleGroupHeader).toBe(true); - const groupHeader = root.locator( - '.maka-tool-activity-card [data-e2e-visible-group-header="true"]', - ); - await expect(groupHeader).toBeVisible(); - await groupHeader.click(); - await waitForPaintedFrames(page); - await waitForStableScrollGeometry(page); - - const pending = await root.evaluate((element) => { - const list = element.querySelector('.maka-chat-message-list'); - if (!list) throw new Error('the transcript content box is missing'); - const rootRect = element.getBoundingClientRect(); - const visibleHeaders = [...element.querySelectorAll( - '.maka-tool-activity-card [role="button"], .maka-tool-activity-card button', - )].filter((candidate) => { - const boundary = candidate.closest('[data-maka-transcript-boundary]'); - const rect = candidate.getBoundingClientRect(); - return Boolean(boundary?.checkVisibility({ contentVisibilityAuto: true })) - && rect.top >= rootRect.top - && rect.bottom <= rootRect.bottom; - }); - if (visibleHeaders.length < 2) { - throw new Error('need two visible controls in the expanded tool card'); - } - const from = visibleHeaders[0]!; - const to = visibleHeaders[1]!; - to.dataset.tabTarget = 'true'; - - // One task: focus the first visible control, append growth, then move focus - // to the second visible control so `focusout` carries a real in-transcript - // `relatedTarget` while the ResizeObserver is still pending. A reader - // stepping between two controls they can both see has not left the tail, so - // the pin must survive — this is the release path the blur-to-body fixtures - // could not reach. - from.focus({ preventScroll: true }); - const growth = document.createElement('div'); - growth.dataset.tabPendingGrowth = 'true'; - growth.style.height = '600px'; - list.append(growth); - to.focus(); - - const toRect = to.getBoundingClientRect(); - return { - focusedTarget: document.activeElement === to, - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - toVisible: toRect.bottom > rootRect.top && toRect.top < rootRect.bottom, - }; - }); - expect(pending.focusedTarget).toBe(true); - expect(pending.toVisible).toBe(true); - expect(pending.distance).toBeGreaterThan(100); - await waitForPaintedFrames(page, 6); - await waitForStableScrollGeometry(page); - - const settled = await root.evaluate((element) => { - const active = document.activeElement as HTMLElement | null; - return { - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - transcriptControlFocused: - active?.closest('[data-maka-transcript-boundary]') !== null, - stillOnTarget: active?.dataset.tabTarget === 'true', - }; - }); - expect(settled.transcriptControlFocused).toBe(true); - expect(settled.stillOnTarget).toBe(true); - expect(settled.distance).toBeLessThanOrEqual(4); -}); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 6733b64e05..b0ba5bfaa4 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -38,42 +38,30 @@ interface FakeRoot { clientHeight: number; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; - addEventListener(type: string, listener: (event?: unknown) => void): void; - removeEventListener(type: string, listener: (event?: unknown) => void): void; + addEventListener(type: string, listener: () => void): void; + removeEventListener(type: string, listener: () => void): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; - /** Dispatch an upward wheel whose default action can move this root. */ - emitUpwardWheel(): void; grow(by: number): void; /** Take height away from the viewport, as a resize or a taller dock does. */ shrinkViewport(by: number): void; } function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): FakeRoot { - const listeners = new Map void>>(); + const listeners = new Set<() => void>(); const root: FakeRoot = { scrollTop: 0, scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], addEventListener(type, listener) { - const bucket = listeners.get(type) ?? new Set(); - bucket.add(listener); - listeners.set(type, bucket); + if (type === 'scroll') listeners.add(listener); }, - removeEventListener(type, listener) { - listeners.get(type)?.delete(listener); + removeEventListener(_type, listener) { + listeners.delete(listener); }, emitScroll() { - for (const listener of [...(listeners.get('scroll') ?? [])]) listener(); - }, - emitUpwardWheel() { - const eventTarget = this; - const event = { - deltaY: -120, - composedPath: () => [eventTarget], - }; - for (const listener of [...(listeners.get('wheel') ?? [])]) listener(event); + for (const listener of [...listeners]) listener(); }, grow(by) { root.scrollHeight += by; @@ -176,47 +164,6 @@ test('a scroll this authority did not write is the reader, and releases the tail }); }); -test('reader movement releases the tail when content grows before the scroll event', () => { - withObservers((resize) => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - assert.equal(root.scrollTop, 2_400); - - // A skipped block materializes in the same rendering opportunity as the - // reader moves upward. The scroll event therefore observes both a changed - // offset and a changed scrollHeight; geometry movement must not erase the - // reader's intent merely because it arrived in the same delivery window. - root.emitUpwardWheel(); - root.scrollTop = 1_900; - root.grow(500); - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, false); - assert.equal(authority.getSnapshot().awayFromTail, true); - - // The resize notification for that materialization arrives afterwards. - // Once released, it must not write the reader back to the new tail. - resize(); - assert.equal(root.scrollTop, 1_900); - }); -}); - -test('viewport-only geometry movement preserves a pinned transcript', () => { - withObservers((resize) => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - - root.shrinkViewport(100); - root.scrollTop = 2_390; - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, true); - - resize(); - assert.equal(root.scrollTop, 2_500); - }); -}); - test('returning to the tail re-pins, and following resumes', () => { withObservers((resize) => { const root = fakeRoot(); @@ -284,24 +231,55 @@ test('a scroll event that arrives late is still this authority\'s own write', () }); }); -test('growth that outruns the write does not read as the reader scrolling up', () => { +test('a moved event past the threshold releases, resolving the race toward the reader', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); assert.equal(root.scrollTop, 2_400); - // The transcript grew, and the scroll event for it arrives before this - // authority has been told to follow it. The offset is 302px from a tail - // that moved — identical, as a position, to a reader who scrolled up. + // The transcript grew and a scroll event lands before this authority has + // followed it, at an offset 300px from the tail that moved. As a position + // this is identical to a reader who scrolled up 300px. Refusing to act on + // it — because the geometry also changed — is #4269: once content-visibility + // placeholders expand on the way up, every reader scroll also moves + // `scrollHeight`, so a guard that stays pinned here re-pins the reader every + // frame. Release is monotonic and this authority owns no write that put them + // here, so the ambiguity resolves toward the reader and the pin lets go. + // + // In a real browser the benign side of this race does not reach this branch + // past the threshold: growth below the tail moves no offset and echoes, and + // growth above is absorbed by anchoring that holds the distance at ~0 — the + // case the next test pins down. root.grow(302); root.scrollTop = 2_402; root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(authority.getSnapshot().awayFromTail, true); + + // With the pin released this authority writes nothing, so the reader keeps + // the offset they were left on rather than being taken to the new tail. + resize(); + assert.equal(root.scrollTop, 2_402); + }); +}); + +test('a moved event anchoring holds within the threshold keeps the pin', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + // Content grew and native anchoring moved the offset with the tail, so the + // distance stays within PIN_THRESHOLD_PX. This is the benign geometry change + // the release must not fire on: the reader never left, and the next growth + // signal follows the tail exactly as before. + root.grow(302); + root.scrollTop = 2_702; + root.emitScroll(); assert.equal(authority.getSnapshot().pinned, true); - // The affordance still knows how far the tail now is, and the next growth - // signal takes the reader back to it. - assert.equal(authority.getSnapshot().awayFromTail, true); resize(); assert.equal(root.scrollTop, 2_702); }); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 8b19ee7d6f..209e8ecd07 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -33,13 +33,12 @@ * and "the reader is dragging" is also just don't touch it — so both of those * are the same instruction to this code: stay out of the way. * - * Being the only writer is what makes the ordinary state exact rather than - * guessed. It remembers the offset it wrote, so a scroll event that finds the - * scroller still on that offset is its own echo and any other stable-geometry - * offset is the reader — by construction, with no timing heuristic. Geometry - * can still move the offset before its scroll event, so upward reader inputs - * release synchronously while their ownership is known. The scroll event stays - * geometry-neutral instead of guessing intent from an intrinsic-size change. + * Being the only writer is what makes the state exact rather than guessed. It + * remembers the offset it wrote, so a scroll event that finds the scroller + * still on that offset is its own echo and any other offset is the reader — by + * construction, and with no dependence on when the event arrives. Astryx had to + * infer that from scroll direction, height deltas and wheel events, and every + * one of those signals has more than one cause. */ import { @@ -54,8 +53,6 @@ import { ChatLayoutScrollButton } from '@astryxdesign/core/Chat'; /** Astryx's own thresholds, so the affordance keeps the feel readers learnt. */ const PIN_THRESHOLD_PX = 10; const BUTTON_THRESHOLD_PX = 100; -const TRANSCRIPT_SELECTOR = '.maka-chat-message-list'; -const TRANSCRIPT_BOUNDARY_SELECTOR = '[data-maka-transcript-boundary]'; export interface TranscriptScrollSnapshot { /** Following the tail: growth writes `scrollTop`. */ @@ -89,30 +86,6 @@ export interface TranscriptScrollAuthority { getSnapshot(): TranscriptScrollSnapshot; } -/** - * A wheel dispatched below the transcript also crosses the transcript listener, - * even when a nested tool output or terminal will consume it. Only a nested - * scroller that can still move in the requested direction owns the gesture. - * At its boundary Chromium may chain the wheel to the transcript unless the - * nested surface explicitly contains overscroll. - */ -export function nestedScrollerConsumesUpwardInput( - path: readonly EventTarget[], - root: HTMLElement, -): boolean { - for (const target of path) { - if (target === root) break; - if (!(target instanceof HTMLElement)) continue; - const style = getComputedStyle(target); - const overflowY = style.overflowY; - if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue; - if (target.scrollHeight <= target.clientHeight) continue; - if (target.scrollTop > 0) return true; - if (['contain', 'none'].includes(style.overscrollBehaviorY)) return true; - } - return false; -} - export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { let root: HTMLElement | null = null; let pinned = true; @@ -162,12 +135,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }; - const releaseTail = (): void => { - pinned = false; - awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; - publish(); - }; - return { attach(next) { root = next; @@ -178,17 +145,31 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // put it on is the echo of that write, however late it arrives; any // other offset is the reader, exactly, and not by inference. Nested // scrollers (a tool output box, a terminal) never reach here at all: - // `scroll` does not bubble, so nested movement never reaches this - // handler. + // `scroll` does not bubble, and there is no `wheel` listener to catch + // instead. if (lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) < 1) { lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; return; } - // A geometry-changing event cannot prove reader intent. Streaming, - // native anchoring and content-visibility correction can all move both - // the offset and scrollHeight before delivery, so input listeners below - // handle the ambiguous upward paths while ownership is still known. + // An event that arrives with the scroll geometry changed cannot say who + // moved the offset: anchoring holding the reader still as turns land + // above, growth that outran this authority's own write, or a resize the + // browser answered by clamping the offset all shift it without the + // reader asking. So this branch never *re-pins* from position — that + // would follow the tail on the strength of a content jump, which is the + // #4269 regression: once content-visibility placeholders expand on the + // way up, every upward scroll also changes `scrollHeight`, and a pin + // re-derived here would snap the reader back to the tail every frame. + // Releasing, though, is monotonic and safe to read from position alone. + // Leaving the tail only ever moves the reader farther from the bottom, + // and no benign geometry change parks a tail-following reader past + // PIN_THRESHOLD_PX — growth appends below their unchanged offset and + // fires no `scroll`. So a moved event that finds them beyond the + // threshold is the reader having left, and the pin releases. Re-pinning + // still needs the stable-geometry path below or an explicit + // pinToTail(). The affordance follows the new distance either way, + // because that is a fact about the viewport rather than about them. const moved = target.scrollHeight !== lastScrollHeight || target.clientHeight !== lastClientHeight; lastScrollHeight = target.scrollHeight; @@ -196,6 +177,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const distance = distanceToTail(); awayFromTail = distance > BUTTON_THRESHOLD_PX; if (moved) { + if (distance > PIN_THRESHOLD_PX) pinned = false; publish(); return; } @@ -206,90 +188,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; target.addEventListener('scroll', onScroll, { passive: true }); - const onWheel = (event: WheelEvent): void => { - // Already released: nothing here writes `scrollTop`, so skip the - // composed-path `getComputedStyle` walk — a forced style recalc on - // every wheel event of the exact gesture the perf gate measures. - if (!pinned) return; - if (event.deltaY >= 0 || target.scrollTop <= 0) return; - if (nestedScrollerConsumesUpwardInput(event.composedPath(), target)) return; - releaseTail(); - }; - target.addEventListener('wheel', onWheel, { passive: true }); - const onKeyDown = (event: KeyboardEvent): void => { - if (!pinned) return; - const upward = ['ArrowUp', 'PageUp', 'Home'].includes(event.key) - || (event.key === ' ' && event.shiftKey); - if (!upward || event.defaultPrevented || target.scrollTop <= 0) return; - if (!(event.target instanceof HTMLElement)) return; - if (event.target !== target && event.target.closest(TRANSCRIPT_SELECTOR) === null) return; - if (event.target.closest('input, textarea, [contenteditable="true"]')) return; - if (nestedScrollerConsumesUpwardInput(event.composedPath(), target)) return; - releaseTail(); - }; - target.addEventListener('keydown', onKeyDown); - const onPointerDown = (event: PointerEvent): void => { - if (event.target !== target || target.scrollTop <= 0) return; - const scrollbarWidth = target.offsetWidth - target.clientWidth; - if (scrollbarWidth <= 0) return; - const rootRect = target.getBoundingClientRect(); - if (event.clientX >= rootRect.right - scrollbarWidth) releaseTail(); - }; - target.addEventListener('pointerdown', onPointerDown, { passive: true }); - // Focus navigation is the one reader action Chromium can perform before - // it delivers the resulting scroll event: focusing a skipped boundary - // materializes it and reveals the target first. Capture the incoming - // target before that browser-owned reveal so a genuinely offscreen - // transcript control releases the tail early. A visible control leaves - // the pin unchanged and subsequent growth follows normally. - let incomingFocus: - | { readonly target: HTMLElement; readonly outsideViewport: boolean } - | undefined; - const isOutsideViewport = (element: HTMLElement): boolean => { - const boundary = element.closest(TRANSCRIPT_BOUNDARY_SELECTOR) ?? element; - const focusedRect = boundary.getBoundingClientRect(); - const rootRect = target.getBoundingClientRect(); - return focusedRect.bottom <= rootRect.top || focusedRect.top >= rootRect.bottom; - }; - const onFocusOut = (event: FocusEvent): void => { - const next = event.relatedTarget; - if (!(next instanceof HTMLElement) || !target.contains(next)) { - incomingFocus = undefined; - return; - } - incomingFocus = { - target: next, - outsideViewport: - next.closest(TRANSCRIPT_SELECTOR) !== null && isOutsideViewport(next), - }; - }; - // The production root always has an ownerDocument. Keep the fallback for - // the lightweight root used by the state-machine tests and other DOM - // adapters that only implement the scroller surface. - const focusEventRoot = target.ownerDocument || target; - focusEventRoot.addEventListener('focusout', onFocusOut, true); - const onFocusIn = (event: FocusEvent): void => { - if (!(event.target instanceof HTMLElement)) { - incomingFocus = undefined; - return; - } - const before = incomingFocus?.target === event.target ? incomingFocus : undefined; - incomingFocus = undefined; - if (!pinned) return; - if (event.target.closest(TRANSCRIPT_SELECTOR) === null) return; - if (before?.outsideViewport === false) return; - const outsideViewport = isOutsideViewport(event.target); - const readerMoved = - lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) >= 1; - if ( - before?.outsideViewport === true - || outsideViewport - || (before === undefined && readerMoved && distanceToTail() > PIN_THRESHOLD_PX) - ) { - releaseTail(); - } - }; - target.addEventListener('focusin', onFocusIn); // Everything that moves the tail without the reader asking, watched in // one place: the scroller's own box, because the tail also moves when the // viewport shrinks (a window resize, a composer that gains a line), and @@ -324,11 +222,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { childList.disconnect(); box.disconnect(); target.removeEventListener('scroll', onScroll); - target.removeEventListener('wheel', onWheel); - target.removeEventListener('keydown', onKeyDown); - target.removeEventListener('pointerdown', onPointerDown); - target.removeEventListener('focusin', onFocusIn); - focusEventRoot.removeEventListener('focusout', onFocusOut, true); lastWrittenTop = undefined; if (root === target) root = null; }; @@ -339,7 +232,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }, releasePin() { - releaseTail(); + pinned = false; + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); }, subscribeToReaderScroll(listener) { readerListeners.add(listener); diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index b50f858e73..5ab67edfb7 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -34,10 +34,7 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; -import { - nestedScrollerConsumesUpwardInput, - useTranscriptScrollAuthority, -} from './transcript-scroll-authority.js'; +import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; export function useChatScroll(input: { scrollRef: RefObject; @@ -172,7 +169,13 @@ export function useChatScroll(input: { // is below. const onWheel = (event: WheelEvent): void => { if (event.deltaY >= 0 || !nearStart()) return; - if (nestedScrollerConsumesUpwardInput(event.composedPath(), root)) return; + for (const target of event.composedPath()) { + if (target === root) break; + if (!(target instanceof HTMLElement)) continue; + const overflowY = getComputedStyle(target).overflowY; + if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue; + if (target.scrollHeight > target.clientHeight && target.scrollTop > 0) return; + } authority.releasePin(); requestEarlier(); }; From c3ad91387504ec1e62b3334008c13510ec0e7d18 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 16:51:07 +0800 Subject: [PATCH 18/19] fix(ui): release the transcript pin on upward reader motion, not distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI showed the pure-distance release regressed two base contracts in transcript-scroll.spec.ts (content-grows-is-followed, gesture-nested-consumed keeps-tail): benign growth fires a moved scroll event whose distance to the now-larger tail exceeds the threshold before writeToTail catches up, and releasing on distance alone dropped the follow the reader never left. Distance cannot separate growth from the reader, but direction can. This authority only ever writes the offset toward the tail, so an offset now above its last write is the reader having moved up — the one thing growth alone never produces. Release the pin only for a moved event whose offset is above lastWrittenTop (and past PIN_THRESHOLD_PX); growth at or past the last write keeps the pin. This still fixes #4269 (an upward gesture that also grows scrollHeight is read by direction, not refused) without releasing on growth. Restores the base "growth that outruns the write" unit contract and adds the upward-under-churn release case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../transcript-scroll-authority.test.ts | 55 +++++++++---------- .../ui/src/transcript-scroll-authority.tsx | 42 ++++++++------ 2 files changed, 52 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index b0ba5bfaa4..382e7131ea 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -231,57 +231,56 @@ test('a scroll event that arrives late is still this authority\'s own write', () }); }); -test('a moved event past the threshold releases, resolving the race toward the reader', () => { +test('growth that outruns the write does not read as the reader scrolling up', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); assert.equal(root.scrollTop, 2_400); - // The transcript grew and a scroll event lands before this authority has - // followed it, at an offset 300px from the tail that moved. As a position - // this is identical to a reader who scrolled up 300px. Refusing to act on - // it — because the geometry also changed — is #4269: once content-visibility - // placeholders expand on the way up, every reader scroll also moves - // `scrollHeight`, so a guard that stays pinned here re-pins the reader every - // frame. Release is monotonic and this authority owns no write that put them - // here, so the ambiguity resolves toward the reader and the pin lets go. - // - // In a real browser the benign side of this race does not reach this branch - // past the threshold: growth below the tail moves no offset and echoes, and - // growth above is absorbed by anchoring that holds the distance at ~0 — the - // case the next test pins down. + // The transcript grew, and the scroll event for it arrives before this + // authority has been told to follow it. The offset is 302px from a tail + // that moved — identical, as a distance, to a reader who scrolled up 300px. + // But the reader would have moved the offset *up*, below this authority's + // last write; this one sits at or past that write, so it is growth and the + // pin must hold or the follow releases itself. root.grow(302); root.scrollTop = 2_402; root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, false); - assert.equal(authority.getSnapshot().awayFromTail, true); + assert.equal(authority.getSnapshot().pinned, true); - // With the pin released this authority writes nothing, so the reader keeps - // the offset they were left on rather than being taken to the new tail. + // The affordance still knows how far the tail now is, and the next growth + // signal takes the reader back to it. + assert.equal(authority.getSnapshot().awayFromTail, true); resize(); - assert.equal(root.scrollTop, 2_402); + assert.equal(root.scrollTop, 2_702); }); }); -test('a moved event anchoring holds within the threshold keeps the pin', () => { +test('a reader scroll under changing geometry still releases the tail', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); assert.equal(root.scrollTop, 2_400); - // Content grew and native anchoring moved the offset with the tail, so the - // distance stays within PIN_THRESHOLD_PX. This is the benign geometry change - // the release must not fire on: the reader never left, and the next growth - // signal follows the tail exactly as before. - root.grow(302); - root.scrollTop = 2_702; + // #4269: the reader scrolls up while the turn is still streaming, so the + // very gesture that moves the offset up also grows `scrollHeight` as + // content-visibility placeholders expand. The event is `moved`, yet the + // offset is now above this authority's last write — the one thing growth + // alone never produces — so it is the reader, and the pin releases even + // though the geometry changed in the same frame. + root.grow(500); + root.scrollTop = 1_000; root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, true); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(authority.getSnapshot().awayFromTail, true); + // Released, this authority writes nothing more; the reader keeps their + // place as the turn keeps growing. + root.grow(4_000); resize(); - assert.equal(root.scrollTop, 2_702); + assert.equal(root.scrollTop, 1_000); }); }); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 209e8ecd07..ae3fbb4ae5 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -152,22 +152,24 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { lastClientHeight = target.clientHeight; return; } - // An event that arrives with the scroll geometry changed cannot say who - // moved the offset: anchoring holding the reader still as turns land - // above, growth that outran this authority's own write, or a resize the - // browser answered by clamping the offset all shift it without the - // reader asking. So this branch never *re-pins* from position — that - // would follow the tail on the strength of a content jump, which is the - // #4269 regression: once content-visibility placeholders expand on the - // way up, every upward scroll also changes `scrollHeight`, and a pin - // re-derived here would snap the reader back to the tail every frame. - // Releasing, though, is monotonic and safe to read from position alone. - // Leaving the tail only ever moves the reader farther from the bottom, - // and no benign geometry change parks a tail-following reader past - // PIN_THRESHOLD_PX — growth appends below their unchanged offset and - // fires no `scroll`. So a moved event that finds them beyond the - // threshold is the reader having left, and the pin releases. Re-pinning - // still needs the stable-geometry path below or an explicit + // An event that arrives with the scroll geometry changed cannot be read + // as the reader from distance alone: anchoring holding them still as + // turns land above, growth that outran this authority's own write, or a + // resize the browser answered by clamping the offset all enlarge the + // distance to the tail without the reader asking. So this branch never + // *re-pins* from position — that would follow the tail on the strength + // of a content jump. #4269 is the mirror of the same ambiguity: once + // #4206 put `content-visibility` on every turn, scrolling up itself + // expands placeholders, so the reader's own gesture changes + // `scrollHeight` too, and a guard that refused to act on any moved event + // left the pin set and let the ResizeObserver snap them back every + // frame. What still separates growth from the reader is direction: this + // authority only ever wrote the offset toward the tail, so an offset now + // *above* its last write is the reader having moved up — monotonic, and + // owned by no write here. Release the pin for that, and only that; a + // moved event that finds the offset at or past the last write is growth + // outrunning the follow, and must keep the pin so the follow survives. + // Re-pinning still needs the stable-geometry path below or an explicit // pinToTail(). The affordance follows the new distance either way, // because that is a fact about the viewport rather than about them. const moved = @@ -177,7 +179,13 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const distance = distanceToTail(); awayFromTail = distance > BUTTON_THRESHOLD_PX; if (moved) { - if (distance > PIN_THRESHOLD_PX) pinned = false; + if ( + lastWrittenTop !== undefined + && target.scrollTop < lastWrittenTop + && distance > PIN_THRESHOLD_PX + ) { + pinned = false; + } publish(); return; } From c033dbb5b149f15abf71a3e129b008fb2d832717 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 4 Sep 2026 23:41:16 +0800 Subject: [PATCH 19/19] fix(ui): let the transcript pin own overflow anchoring for #4269 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direction predicate misfired because `overflow-anchor: auto` is a second writer of `scrollTop`. While pinned, the browser's anchor adjustments are overwritten on the next frame, but the `scroll` events they emit still fire, and the `moved` guard swallowed the reader's upward wheel along with that synthetic movement — snapping them back to the tail. Let the pin own anchoring instead: `overflow-anchor: none` while following the tail, handed back on release. With no competing writer, any non-echo scroll event while pinned is the reader, exactly, so the guard reduces to `if (moved && !pinned) return;` and the direction comparison is gone. Drop the static `overflow-anchor` rule from chat-message.css so it cannot fight the authority. Diagnosis and the measured fix are @Astro-Han's (fix/transcript-pin-owns-anchoring): 6/6 upward escapes vs 0/6 on main under 4ms/8ms streaming growth, history paging unaffected. Drop what the anchoring fix makes moot: the two "releases the live tail" E2E specs (the release path is now the unit-tested anchoring contract), the `nativePerformanceTest` oversized case whose recalc spike was an e2e-fixture artifact (`transition-duration: 0.01ms !important`), and the unused `promptRailMotionWindow`. Sub-turn containment stays — its win is hover, not scroll. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/fixtures.ts | 23 --- .../e2e/native-transcript-perf.spec.ts | 52 ----- .../desktop/e2e/oversized-turn-render.spec.ts | 195 ------------------ .../src/renderer/styles/chat-message.css | 16 +- .../transcript-scroll-authority.test.ts | 67 +++--- .../ui/src/transcript-scroll-authority.tsx | 63 +++--- 6 files changed, 73 insertions(+), 343 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 04cf922b04..7d438274b5 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -580,7 +580,6 @@ type E2eTestFixtures = { promptRailWindow: Page; partialHistoryWindow: Page; oversizedTurnWindow: Page; - promptRailMotionWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; @@ -783,28 +782,6 @@ export const test = base.extend({ showWindow: true, }, use); }, - // The same transcript, scrolling the way the shipped app scrolls. Separate - // from `promptRailWindow` because it is only the jump that needs a scroll - // still in flight, and paying for one everywhere costs several seconds per - // window and settles less predictably. - promptRailMotionWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - // The transcript and the fixture attributes arrive on two unordered - // async paths: `runDeferredStartupRefreshes` fires `refreshSessions()` - // and `applyE2eFixture()` side by side, and only the second one — after - // its `e2eFixture.getState()` IPC resolves — writes - // `data-maka-scroll-motion`. A turn can therefore paint while the - // document still says nothing about scroll motion. Requiring both in one - // selector is what makes "this window scrolls smoothly" true by the time - // a test body reads it. - readinessSelector: 'html[data-maka-scroll-motion="smooth"] [data-turn-id]', - e2eFixtureScenario: 'chat-prompt-rail', - locale: 'zh', - showWindow: true, - scrollMotion: 'smooth', - }, use); - }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts index 7f820dd422..8631bd63fb 100644 --- a/apps/desktop/e2e/native-transcript-perf.spec.ts +++ b/apps/desktop/e2e/native-transcript-perf.spec.ts @@ -24,13 +24,8 @@ import { ensureSidebarExpanded, expect, test } from './fixtures'; const PERF_ENABLED = process.env.MAKA_TRANSCRIPT_PERF === '1'; const STRESS_ENABLED = process.env.MAKA_TRANSCRIPT_STRESS === '1'; -// Long-animation-frame delivery includes the native compositor. Xvfb's -// software/virtual display is useful for functional E2E, but is not comparable -// to the macOS arm64 environment in which this release threshold was measured. -const NATIVE_MACOS_ARM64_PERF_GATE = process.platform === 'darwin' && process.arch === 'arm64'; const performanceTest = PERF_ENABLED ? test : test.skip; const stressTest = STRESS_ENABLED ? test : test.skip; -const nativePerformanceTest = PERF_ENABLED && NATIVE_MACOS_ARM64_PERF_GATE ? test : test.skip; const SCROLLER = '[data-chat-scroll-container="true"]'; interface BrowserCounters { @@ -328,53 +323,6 @@ performanceTest('warm native transcript scroll metrics', async ({ promptRailWind console.log(`TRANSCRIPT_PERF ${JSON.stringify(result)}`); }); -nativePerformanceTest('oversized single Turn upward scroll metrics', async ({ - oversizedTurnWindow: page, -}) => { - test.setTimeout(90_000); - await page.setViewportSize({ width: 1_000, height: 700 }); - await expect(page.locator('[data-turn-id="turn-oversized-fixture"]')).toHaveCount(1); - const cdp = await page.context().newCDPSession(page); - await cdp.send('Performance.enable'); - await prepareFrameRecorder(page); - await moveToTail(page); - const distance = await page.evaluate((selector) => { - const root = document.querySelector(selector); - if (!root) throw new Error('the chat scroll container is missing'); - return root.scrollHeight - root.clientHeight; - }, SCROLLER); - const before = await performanceMetrics(cdp); - const frames = await scrollGesture(page, -distance, 480); - const after = await performanceMetrics(cdp); - const skippedSegments = await page.locator('[data-maka-transcript-boundary]').evaluateAll((elements) => - (elements as HTMLElement[]).filter((element) => - !element.checkVisibility({ contentVisibilityAuto: true })).length, - ); - const result = { - distance, - taskMs: metricDelta(before, after, 'TaskDuration') * 1_000, - layoutMs: metricDelta(before, after, 'LayoutDuration') * 1_000, - recalcStyleMs: metricDelta(before, after, 'RecalcStyleDuration') * 1_000, - frameP95Ms: percentile(frames.intervals, 0.95), - frameP99Ms: percentile(frames.intervals, 0.99), - frameMaxMs: Math.max(...frames.intervals), - loafOver50Ms: frames.loafDurations.filter((duration) => duration > 50).length, - loafMaxMs: Math.max(0, ...frames.loafDurations), - loafSupported: frames.loafSupported, - skippedSegments, - }; - console.log(`OVERSIZED_TURN_PERF ${JSON.stringify(result)}`); - expect( - frames.loafSupported, - 'Chromium does not support the long-animation-frame release metric', - ).toBe(true); - expect( - result.loafOver50Ms, - `oversized-Turn upward scroll exceeded the 50 ms Long Animation Frame gate: ${JSON.stringify(result)}`, - ).toBe(0); - expect(result.skippedSegments).toBeGreaterThan(0); -}); - stressTest('600+ Turn repeated paging keeps the active range on a memory plateau', async ({ promptRailWindow: page, }) => { diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts index 60f7ed4b1b..76a8e9a781 100644 --- a/apps/desktop/e2e/oversized-turn-render.spec.ts +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -20,33 +20,6 @@ import { expect, test } from './fixtures'; const SEGMENT = '[data-maka-transcript-boundary]'; -const SCROLLER = '[data-chat-scroll-container="true"]'; - -async function waitForPaintedFrames(page: import('@playwright/test').Page, frames = 4) { - await page.evaluate(async (count) => { - for (let frame = 0; frame < count; frame += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - }, frames); -} - -async function waitForStableScrollGeometry(page: import('@playwright/test').Page) { - await page.evaluate(async (selector) => { - const root = document.querySelector(selector); - if (!root) throw new Error('the chat scroll container is missing'); - let previous = ''; - let stableFrames = 0; - for (let frame = 0; frame < 60; frame += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - const current = [root.scrollTop, root.scrollHeight, root.clientHeight].join(':'); - if (current === previous) stableFrames += 1; - else stableFrames = 0; - if (stableFrames >= 4) return; - previous = current; - } - throw new Error('transcript scroll geometry did not settle'); - }, SCROLLER); -} test('an oversized single Turn skips offscreen timeline blocks', async ({ oversizedTurnWindow: page, @@ -77,171 +50,3 @@ test('an oversized single Turn skips offscreen timeline blocks', async ({ element.checkVisibility({ contentVisibilityAuto: true }), )).toBe(true); }); - -test('upward scrolling releases the live tail while skipped geometry materializes', async ({ - oversizedTurnWindow: page, -}) => { - await page.setViewportSize({ width: 900, height: 700 }); - const root = page.locator(SCROLLER); - await root.evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - await waitForPaintedFrames(page); - - const atTail = await root.evaluate((element) => - element.scrollHeight - element.scrollTop - element.clientHeight, - ); - expect(atTail).toBeLessThanOrEqual(4); - - // Force the ordering from the field report: the wheel begins materializing - // an intrinsic-size block before Chromium delivers the resulting scroll. - // Appending below the reader is deterministic synthetic growth; approaching - // the skipped timeline blocks above adds the real content-visibility change. - await root.evaluate((element) => { - const list = element.querySelector('.maka-chat-message-list'); - if (!list) throw new Error('the transcript content box is missing'); - element.addEventListener('wheel', () => { - const growth = document.createElement('div'); - growth.dataset.oversizedTurnGrowth = 'true'; - growth.style.height = '600px'; - list.append(growth); - }, { capture: true, once: true }); - }); - - await root.hover(); - await page.mouse.wheel(0, -500); - await waitForPaintedFrames(page, 6); - await waitForStableScrollGeometry(page); - - const released = await root.evaluate((element) => { - const rootRect = element.getBoundingClientRect(); - const center = (rootRect.top + rootRect.bottom) / 2; - const anchor = [...element.querySelectorAll('[data-maka-transcript-boundary]')] - .filter((candidate) => { - const rect = candidate.getBoundingClientRect(); - return rect.bottom > rootRect.top && rect.top < rootRect.bottom; - }) - .sort((left, right) => { - const leftRect = left.getBoundingClientRect(); - const rightRect = right.getBoundingClientRect(); - return Math.abs((leftRect.top + leftRect.bottom) / 2 - center) - - Math.abs((rightRect.top + rightRect.bottom) / 2 - center); - })[0]; - if (!anchor) throw new Error('the reader scroll has no visible reading anchor'); - anchor.dataset.readingAnchor = 'true'; - return { - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - }; - }); - expect(released.distance).toBeGreaterThan(100); - - // A later delivery must preserve the released position too. Without the - // release, the scroll authority writes the latest tail on this resize. - await root.evaluate((element) => { - const growth = element.querySelector('[data-oversized-turn-growth]'); - if (!growth) throw new Error('the synthetic growth box is missing'); - growth.style.height = '900px'; - }); - await waitForPaintedFrames(page); - await waitForStableScrollGeometry(page); - - const afterGrowth = await root.evaluate((element) => { - const rootRect = element.getBoundingClientRect(); - const anchor = element.querySelector('[data-reading-anchor]'); - const anchorRect = anchor?.getBoundingClientRect(); - return { - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - anchorVisible: anchorRect != null - && anchorRect.bottom > rootRect.top - && anchorRect.top < rootRect.bottom, - }; - }); - expect(afterGrowth.distance).toBeGreaterThan(released.distance); - expect(afterGrowth.anchorVisible).toBe(true); -}); - -test('PageUp releases the live tail while skipped geometry materializes', async ({ - oversizedTurnWindow: page, -}) => { - await page.setViewportSize({ width: 900, height: 700 }); - const root = page.locator(SCROLLER); - await root.evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - await waitForPaintedFrames(page); - - await root.evaluate((element) => { - const list = element.querySelector('.maka-chat-message-list'); - if (!list) throw new Error('the transcript content box is missing'); - // The production scroller carries no tabindex, so `event.target === root` - // is unreachable there. A real PageUp is dispatched from a focused control - // inside the list and reaches the handler through - // `event.target.closest('.maka-chat-message-list')`. Focus a visible card - // header to exercise that path instead of focusing the scroller itself. - const rootRect = element.getBoundingClientRect(); - const header = [...element.querySelectorAll( - '.maka-tool-activity-card [role="button"][tabindex="0"]', - )].find((candidate) => { - const boundary = candidate.closest('[data-maka-transcript-boundary]'); - const rect = candidate.getBoundingClientRect(); - return boundary?.checkVisibility({ contentVisibilityAuto: true }) - && rect.top >= rootRect.top - && rect.bottom <= rootRect.bottom; - }); - if (!header) throw new Error('the visible tool-card header is missing'); - header.focus({ preventScroll: true }); - element.addEventListener('keydown', (event) => { - if (event.key !== 'PageUp') return; - const growth = document.createElement('div'); - growth.dataset.keyboardGrowth = 'true'; - growth.style.height = '600px'; - list.append(growth); - }, { capture: true, once: true }); - }); - - await page.keyboard.press('PageUp'); - await waitForPaintedFrames(page, 6); - await waitForStableScrollGeometry(page); - const released = await root.evaluate((element) => { - const rootRect = element.getBoundingClientRect(); - const center = (rootRect.top + rootRect.bottom) / 2; - const anchor = [...element.querySelectorAll('[data-maka-transcript-boundary]')] - .filter((candidate) => { - const rect = candidate.getBoundingClientRect(); - return rect.bottom > rootRect.top && rect.top < rootRect.bottom; - }) - .sort((left, right) => { - const leftRect = left.getBoundingClientRect(); - const rightRect = right.getBoundingClientRect(); - return Math.abs((leftRect.top + leftRect.bottom) / 2 - center) - - Math.abs((rightRect.top + rightRect.bottom) / 2 - center); - })[0]; - if (!anchor) throw new Error('the keyboard scroll has no visible reading anchor'); - anchor.dataset.readingAnchor = 'true'; - return { - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - }; - }); - expect(released.distance).toBeGreaterThan(100); - - await root.evaluate((element) => { - const growth = element.querySelector('[data-keyboard-growth]'); - if (!growth) throw new Error('the keyboard growth box is missing'); - growth.style.height = '900px'; - }); - await waitForPaintedFrames(page); - await waitForStableScrollGeometry(page); - const afterGrowth = await root.evaluate((element) => { - const rootRect = element.getBoundingClientRect(); - const anchor = element.querySelector('[data-reading-anchor]'); - const anchorRect = anchor?.getBoundingClientRect(); - return { - distance: element.scrollHeight - element.scrollTop - element.clientHeight, - anchorVisible: anchorRect != null - && anchorRect.bottom > rootRect.top - && anchorRect.top < rootRect.bottom, - }; - }); - expect(afterGrowth.distance).toBeGreaterThan(released.distance); - expect(afterGrowth.anchorVisible).toBe(true); -}); diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 953df2c3c3..1da8f7f140 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,15 +42,13 @@ 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. */ -[data-chat-scroll-container='true'] { - overflow-anchor: auto; -} +/* Inserting earlier turns above the reader must not move what they are reading. + The browser's scroll anchoring does exactly that — but only once the reader + has left the tail. While the tail is pinned, anchoring is a second writer of + `scrollTop` whose adjustments reach the scroll authority as events it cannot + tell from the reader (#4269), so the pin owns `overflow-anchor` directly: + `transcript-scroll-authority` sets `none` while following the tail and hands + the default back on release. No static rule here, or it would fight that. */ .maka-transcript-turn { display: flex; diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index eff52c7cda..7c65161c15 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -36,6 +36,8 @@ interface FakeRoot { scrollTop: number; scrollHeight: number; clientHeight: number; + /** The pin owns `overflow-anchor`, so the authority writes it here. */ + style: { overflowAnchor: string }; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; addEventListener(type: string, listener: () => void): void; @@ -53,6 +55,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F scrollTop: 0, scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, + style: { overflowAnchor: '' }, children: [{}], addEventListener(type, listener) { if (type === 'scroll') listeners.add(listener); @@ -231,32 +234,6 @@ test('a scroll event that arrives late is still this authority\'s own write', () }); }); -test('growth that outruns the write does not read as the reader scrolling up', () => { - withObservers((resize) => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - assert.equal(root.scrollTop, 2_400); - - // The transcript grew, and the scroll event for it arrives before this - // authority has been told to follow it. The offset is 302px from a tail - // that moved — identical, as a distance, to a reader who scrolled up 300px. - // But the reader would have moved the offset *up*, below this authority's - // last write; this one sits at or past that write, so it is growth and the - // pin must hold or the follow releases itself. - root.grow(302); - root.scrollTop = 2_402; - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, true); - - // The affordance still knows how far the tail now is, and the next growth - // signal takes the reader back to it. - assert.equal(authority.getSnapshot().awayFromTail, true); - resize(); - assert.equal(root.scrollTop, 2_702); - }); -}); - test('a reader scroll under changing geometry still releases the tail', () => { withObservers((resize) => { const root = fakeRoot(); @@ -322,13 +299,8 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { root.emitScroll(); assert.equal(heard, 0); - // Content arriving, with anchoring moving the offset to hold the reader. - root.grow(500); - root.scrollTop = 2_900; - root.emitScroll(); - assert.equal(heard, 0); - - // The reader, at last. + // The reader moves the offset the authority did not write. With anchoring + // off under the pin, this is the only thing a non-echo event can be. root.scrollTop = 900; root.emitScroll(); assert.equal(heard, 1); @@ -339,3 +311,32 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { assert.equal(heard, 1); }); }); + +test('the pin owns overflow anchoring, off while pinned and back on release', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + + // Pinned from the start: the browser must not anchor under a following tail, + // or its adjustments arrive as scroll events indistinguishable from the + // reader — the #4269 snap-back. + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.style.overflowAnchor, 'none'); + + // The reader leaves the tail; anchoring is handed back so content landing + // above holds their place. + root.scrollTop = 900; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(root.style.overflowAnchor, ''); + + // Returning to the tail re-pins and takes anchoring back. + authority.pinToTail(); + assert.equal(root.style.overflowAnchor, 'none'); + + // Detaching restores the browser default. + detach(); + assert.equal(root.style.overflowAnchor, ''); + }); +}); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index ae3fbb4ae5..1e325b148b 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -29,9 +29,15 @@ * !pinned → nothing here writes `scrollTop`, ever * * "Keep the reader where they were reading" is the definition of - * `overflow-anchor: auto`, which is already the initial value and costs nothing, - * and "the reader is dragging" is also just don't touch it — so both of those - * are the same instruction to this code: stay out of the way. + * `overflow-anchor`, and once the reader has left the tail that is exactly the + * behaviour this code wants — so on release it hands anchoring back to the + * browser and stays out of the way. While pinned it does the opposite and turns + * anchoring off, because a second writer moving `scrollTop` under a following + * tail is not help: its adjustments are overwritten on the next frame, but the + * `scroll` events they emit are indistinguishable from the reader, and the + * guard would swallow a real upward gesture along with them (#4269). The pin + * owns anchoring, so while it is set this authority is genuinely the only + * writer. * * Being the only writer is what makes the state exact rather than guessed. It * remembers the offset it wrote, so a scroll event that finds the scroller @@ -135,6 +141,13 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }; + // The pin owns overflow anchoring. Off while following the tail so the browser + // is not a competing writer; handed back on release so a reader who left the + // tail keeps their place as turns land above them. + const ownAnchoring = (): void => { + if (root) root.style.overflowAnchor = pinned ? 'none' : ''; + }; + return { attach(next) { root = next; @@ -152,44 +165,26 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { lastClientHeight = target.clientHeight; return; } - // An event that arrives with the scroll geometry changed cannot be read - // as the reader from distance alone: anchoring holding them still as - // turns land above, growth that outran this authority's own write, or a - // resize the browser answered by clamping the offset all enlarge the - // distance to the tail without the reader asking. So this branch never - // *re-pins* from position — that would follow the tail on the strength - // of a content jump. #4269 is the mirror of the same ambiguity: once - // #4206 put `content-visibility` on every turn, scrolling up itself - // expands placeholders, so the reader's own gesture changes - // `scrollHeight` too, and a guard that refused to act on any moved event - // left the pin set and let the ResizeObserver snap them back every - // frame. What still separates growth from the reader is direction: this - // authority only ever wrote the offset toward the tail, so an offset now - // *above* its last write is the reader having moved up — monotonic, and - // owned by no write here. Release the pin for that, and only that; a - // moved event that finds the offset at or past the last write is growth - // outrunning the follow, and must keep the pin so the follow survives. - // Re-pinning still needs the stable-geometry path below or an explicit - // pinToTail(). The affordance follows the new distance either way, - // because that is a fact about the viewport rather than about them. + // While pinned, anchoring is off — the pin owns it — so no second writer + // moves the offset: any event that is not this authority's echo is the + // reader, and reading their position is exact even when growth changed + // the geometry in the same frame. Once released, anchoring is back and a + // geometry-changing event is the content or the viewport moving under a + // reader this code no longer follows, not a re-pin signal — so a `moved` + // event while unpinned is left alone. Only a stable-geometry event, or + // the reader arriving back at the tail, moves the pin. const moved = target.scrollHeight !== lastScrollHeight || target.clientHeight !== lastClientHeight; lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; const distance = distanceToTail(); awayFromTail = distance > BUTTON_THRESHOLD_PX; - if (moved) { - if ( - lastWrittenTop !== undefined - && target.scrollTop < lastWrittenTop - && distance > PIN_THRESHOLD_PX - ) { - pinned = false; - } + if (moved && !pinned) { publish(); return; } pinned = distance <= PIN_THRESHOLD_PX; + ownAnchoring(); publish(); for (const listener of [...readerListeners]) listener(); }; @@ -225,22 +220,28 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const childList = new MutationObserver(observeBox); childList.observe(target, { childList: true }); observeBox(); + ownAnchoring(); if (pinned) writeToTail(); return () => { childList.disconnect(); box.disconnect(); target.removeEventListener('scroll', onScroll); + // Hand anchoring back to the browser: the pin no longer owns this + // scroller, so its default `overflow-anchor` must be restored. + target.style.overflowAnchor = ''; lastWrittenTop = undefined; if (root === target) root = null; }; }, pinToTail() { pinned = true; + ownAnchoring(); writeToTail(); publish(); }, releasePin() { pinned = false; + ownAnchoring(); awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); },