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..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 { @@ -323,6 +327,61 @@ 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.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); + 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, + ); + 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); +}); + 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..fffb7abad0 --- /dev/null +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -0,0 +1,269 @@ +/* + * 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 { COMPOSER_INPUT, 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(','); +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, +}) => { + 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); +}); + +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); +}); + +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, + 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, + activeTop: active?.getBoundingClientRect().top ?? Number.NaN, + }; + }); + expect(afterGrowth.target).toBe(true); + expect(afterGrowth.distance).toBeGreaterThan(focused.distance); + // 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); +}); + +test('visible composer 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, 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 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); + 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'; + list.append(secondGrowth); + }); + await waitForPaintedFrames(page, 6); + + const result = await root.evaluate((element, composerSelector) => ({ + distance: element.scrollHeight - element.scrollTop - element.clientHeight, + composerFocused: document.activeElement === element.querySelector(composerSelector), + }), COMPOSER_INPUT); + expect(result.composerFocused).toBe(true); + expect(result.distance).toBeLessThanOrEqual(4); +}); 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' diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index aaf66433a9..d90a0dd740 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -38,30 +38,60 @@ 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; + 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; } 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); + }, + 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; @@ -83,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` @@ -164,6 +217,70 @@ 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('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 e2a20e5b0a..19c2fce4bf 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -33,12 +33,16 @@ * 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. 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 { @@ -53,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`. */ @@ -86,6 +98,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 +172,12 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }; + const releaseTail = (): void => { + pinned = false; + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); + }; + return { attach(next) { root = next; @@ -145,8 +188,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 +221,78 @@ 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 }); + // 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. + // `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(); + 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 ( + 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 @@ -212,6 +327,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { childList.disconnect(); box.disconnect(); target.removeEventListener('scroll', onScroll); + target.removeEventListener('wheel', onWheel); + target.removeEventListener('focusin', onFocusIn); + focusEventRoot.removeEventListener('focusout', onFocusOut, true); lastWrittenTop = undefined; if (root === target) root = null; }; @@ -222,9 +340,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(); };