diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 145b493b53..bcc0b0c4d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,6 +443,12 @@ jobs: if: steps.plan.outputs.storybook == 'true' run: npm --workspace @maka/desktop run smoke:storybook + - name: Transcript geometry invariants + if: steps.plan.outputs.storybook == 'true' + env: + GEOMETRY_REPETITIONS: '1' + run: xvfb-run -a node scripts/perf/geometry-ablation.mjs --assert-stable + - name: Update stable Rust for CLI packaging if: steps.plan.outputs.cli_package == 'true' run: rustup update stable --no-self-update diff --git a/.github/workflows/performance-frontend.yml b/.github/workflows/performance-frontend.yml index c1ce145005..1248ee945b 100644 --- a/.github/workflows/performance-frontend.yml +++ b/.github/workflows/performance-frontend.yml @@ -46,6 +46,9 @@ jobs: - name: Storybook measurements if: ${{ !cancelled() && steps.browsers.outcome == 'success' }} run: xvfb-run -a node scripts/perf/storybook.mjs + - name: Fixed-range layout ablation + if: ${{ !cancelled() && steps.browsers.outcome == 'success' }} + run: xvfb-run -a node scripts/perf/geometry-ablation.mjs - name: Upload raw measurements and diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 3d49a2a75d..030561efc4 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -67,7 +67,11 @@ }, "transcript-scroll-cost.spec.ts": { "tests": 1, - "electron": "One traversal exercises real Host transcript paging through preload/IPC, bounded retained ranges, reader displacement when pages install, and a return-to-tail Host read. Chromium containment and fixture motion run in the TranscriptRenderCost story." + "electron": "One traversal exercises real Host transcript paging through preload/IPC, bounded retained ranges, reader displacement when pages install, and a return-to-tail Host read. Fixture motion runs in the TranscriptRenderCost story." + }, + "scroll-geometry.spec.ts": { + "tests": 1, + "electron": "a real Host history batch arrives during a held native scrollbar drag; release must publish that range while preserving the reading Turn and allow return to latest" }, "workhub-layout.spec.ts": { "tests": 2, diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts new file mode 100644 index 0000000000..e530a28142 --- /dev/null +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Real native scrollbar input: stable held geometry, preserved reading anchor, +// and history progress after release. Fixed-range cold scrolling runs in CI too. +import { test, expect } from '@playwright/test'; +import { withE2eWindow } from './fixtures'; + +test('native thumb keeps its geometry and releases history without moving the reader', async () => { + test.setTimeout(180_000); + await withE2eWindow( + { + seed: false, + readinessSelector: '[data-turn-id]', + e2eFixtureScenario: 'chat-prompt-rail', + locale: 'zh-CN', + showWindow: true, + }, + async (page) => { + await page.setViewportSize({ width: 1000, height: 700 }); + const cdp = await page.context().newCDPSession(page); + await page.addInitScript(() => { + const style = document.createElement('style'); + style.textContent = ` + [data-chat-scroll-container] { scroll-behavior:auto !important; scrollbar-width:auto !important; scrollbar-color:auto !important; } + [data-chat-scroll-container]::-webkit-scrollbar { width:14px; } + [data-chat-scroll-container]::-webkit-scrollbar-thumb { background:#777; min-height:0; border:0; } + [data-chat-scroll-container]::-webkit-scrollbar-track { background:#ddd; }`; + const append = () => document.documentElement.append(style); + if (document.documentElement) append(); + else + new MutationObserver((_, observer) => { + if (document.documentElement) { + append(); + observer.disconnect(); + } + }).observe(document, { childList: true }); + }); + { + await page.reload(); + await expect(page.locator('[data-turn-id]').first()).toBeVisible(); + const returnLatest = page.getByRole('button', { + name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, + }); + if (await returnLatest.isVisible()) await returnLatest.click(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1, { + timeout: 30_000, + }); + await page.evaluate(() => document.fonts.ready); + // Baseline app admission, not a geometry-settled assertion. Prefetch can + // still happen during the subsequent held drag and must be recorded. + await page.waitForTimeout(500); + const start = await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]')!; + const box = root.getBoundingClientRect(); + const state = { + held: false, + done: false, + pointerDown: 0, + pointerUp: 0, + readingId: undefined as string | undefined, + frames: [] as Array<{ + h: number; + t: number; + v: number; + range: string; + held: boolean; + ms: number; + anchorTop?: number; + }>, + }; + (window as any).__windowGeometry = state; + root.addEventListener('pointerdown', () => { + state.pointerDown++; + state.held = true; + }); + document.addEventListener('pointerup', () => { + state.pointerUp++; + state.held = false; + }); + const frame = () => { + const turns = [...root.querySelectorAll('.maka-transcript-turn')]; + state.frames.push({ + h: root.scrollHeight, + t: root.scrollTop, + v: root.clientHeight, + range: turns.map((t) => t.dataset.transcriptTurnId).join(','), + held: state.held, + ms: performance.now(), + anchorTop: state.readingId + ? root.querySelector(`[data-turn-id="${state.readingId}"]`)?.getBoundingClientRect() + .top + : undefined, + }); + if (!state.done) requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + return { + x: box.right - 7, + top: box.top, + v: root.clientHeight, + h: root.scrollHeight, + t: root.scrollTop, + gutter: root.offsetWidth - root.clientWidth, + }; + }); + expect(start.gutter, 'a real classic scrollbar must be present').toBeGreaterThanOrEqual(12); + expect(start.h).toBeGreaterThan(start.v); + const startY = start.top + ((start.t + start.v / 2) * start.v) / start.h; + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: start.x, y: startY }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: start.x, + y: startY, + button: 'left', + buttons: 1, + clickCount: 1, + }); + for (let step = 1; step <= 40; step++) { + const y = startY + ((start.top + 8 - startY) * step) / 40; + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: start.x, + y, + button: 'left', + buttons: 1, + }); + await page.waitForTimeout(25); + } + await page.waitForTimeout(400); + const reading = await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]')!; + const top = root.getBoundingClientRect().top; + const turn = [...root.querySelectorAll('[data-turn-id]')].find( + (el) => el.getBoundingClientRect().bottom > top, + )!; + (window as any).__windowGeometry.readingId = turn.dataset.turnId; + return { id: turn.dataset.turnId!, top: turn.getBoundingClientRect().top }; + }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: start.x, + y: start.top + 8, + button: 'left', + buttons: 0, + clickCount: 1, + }); + // A loaded runner can deliver fewer than three frames in 300ms. Keep + // observing through the actual publication instead of stopping on time. + await page.waitForFunction(() => { + const state = (window as any).__windowGeometry; + const held = state.frames.find((frame: any) => frame.held); + const released = state.frames.filter((frame: any) => !frame.held && frame.anchorTop !== undefined); + return released.length > 2 && released.some((frame: any) => frame.range !== held.range); + }).catch(async (error) => { + await test.info().attach('scroll-geometry-frames', { + body: JSON.stringify(await page.evaluate(() => (window as any).__windowGeometry)), + contentType: 'application/json', + }); + throw error; + }); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); + const result = await page.evaluate(() => { + const state = (window as any).__windowGeometry; + state.done = true; + return state; + }); + const held = result.frames.filter((f: any) => f.held); + await test.info().attach('scroll-geometry-frames', { + body: JSON.stringify(result), + contentType: 'application/json', + }); + const heightDrift = + Math.max(...held.map((f: any) => f.h)) - Math.min(...held.map((f: any) => f.h)); + const ranges = new Set(held.map((f: any) => f.range)); + expect(result.pointerDown).toBe(1); + expect(result.pointerUp).toBe(1); + expect(heightDrift, 'height must remain constant while held').toBeLessThanOrEqual(1); + expect(ranges.size, 'resident membership must remain constant while held').toBe(1); + expect( + Math.max(0, ...held.slice(1).map((f: any, i: number) => f.t - held[i].t)), + 'upward native drag must not reverse', + ).toBeLessThanOrEqual(1); + const released = result.frames.filter((f: any) => !f.held && f.anchorTop !== undefined); + expect( + Math.max(...released.map((f: any) => Math.abs(f.anchorTop - reading.top))), + 'reading anchor must survive every release frame', + ).toBeLessThanOrEqual(1); + await expect + .poll(() => + page + .locator('.maka-transcript-turn') + .evaluateAll((els) => + els.map((el) => (el as HTMLElement).dataset.transcriptTurnId).join(','), + ), + ) + .not.toBe(held[0].range); + const anchor = page.locator('[data-turn-id="' + reading.id + '"]'); + await expect(anchor).toHaveCount(1); + await expect + .poll(async () => Math.abs((await anchor.boundingBox())!.y - reading.top)) + .toBeLessThanOrEqual(1); + await page + .getByRole('button', { + name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, + }) + .click(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + expect( + Math.max(...held.map((f: any) => f.t)) - Math.min(...held.map((f: any) => f.t)), + 'native thumb drag must actually scroll', + ).toBeGreaterThan(100); + } + }, + ); +}); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 4b5efff725..45048ffe72 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -64,7 +64,7 @@ declare global { interface Window { __makaTranscriptDisplacement?: { boundaries: TranscriptBoundary[]; - record(on: boolean): void; + isSettled(): boolean; stop(): void; }; } @@ -149,25 +149,38 @@ async function observeDisplacement(page: Page): Promise { key: [...tops.keys()].join(','), }; }; - // Only frames the reader is not scrolling through can be compared: a wheel - // tick moves every Turn on screen by its own delta, which is - // indistinguishable from a page that moved them. Which frames those are is - // told, not inferred — the gesture and the rAF that reads it land in the - // same frame in an order nothing here controls, and a reading that catches - // one tick reports exactly one tick of displacement. + // Arm in the page's native event dispatch, before the authority's deferred + // publication. Arming from Playwright after wheel() returns races the same + // rendering frames that publish the range and can miss every boundary. let recording = false; + const record = (on: boolean): void => { + if (recording === on) return; + recording = on; + previous = read(); + settled = null; + }; + const onWheel = (event: Event): void => { + const { deltaY } = event as WheelEvent; + const remaining = deltaY < 0 ? scroller.scrollTop + : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop; + // Edge input cannot move the viewport and may never emit scrollend. + record(remaining <= 0); + }; + const onScrollEnd = (): void => record(true); + scroller.addEventListener('wheel', onWheel, { capture: true, passive: true }); + scroller.addEventListener('scrollend', onScrollEnd, { capture: true }); const state: { boundaries: unknown[]; - record(on: boolean): void; + isSettled(): boolean; stop(): void; } = { boundaries: [], - record: (on: boolean) => { - recording = on; - previous = read(); - settled = null; + isSettled: () => recording && settled === null, + stop: () => { + running = false; + scroller.removeEventListener('wheel', onWheel, true); + scroller.removeEventListener('scrollend', onScrollEnd, true); }, - stop: () => { running = false; }, }; let running = true; let previous = read(); @@ -224,15 +237,6 @@ async function observeDisplacement(page: Page): Promise { }, SCROLLER); } -/** Opens the measurement window, or closes it around the reader's own gesture. */ -async function recordDisplacement(page: Page, on: boolean): Promise { - await page.evaluate((value) => { - const state = window.__makaTranscriptDisplacement; - if (!state) throw new Error('the transcript displacement probe is missing'); - state.record(value); - }, on); -} - async function displacement(page: Page): Promise { return page.evaluate(() => { const state = window.__makaTranscriptDisplacement; @@ -303,6 +307,9 @@ async function returnToLatest(page: Page): Promise { * range changes, whatever Turn the reader can still see must hold its viewport * position. * + * Each burst contains consecutive native wheel ticks. Publication boundaries + * are sampled after scrollend, separately from the reader's own movement. + * * Displacement in pixels rather than frame timings on purpose — see this file's * header for what happened to the timing assertions this suite replaced. A * stall and a jump have the same cause here (a page boundary that moves @@ -328,18 +335,18 @@ test('Host history paging stays bounded, preserves the reader and returns to lat if (firstBefore === 'turn-prompt-rail-1') break; await expect .poll(async () => { - await recordDisplacement(page, false); await wheel(page, cdp, { ticks: 12, deltaY: -120 }); - // The hand comes off the wheel here. A page requested by the gesture - // lands in the quiet that follows — which is also when a reader would - // see it move — so that quiet is the whole of what is measured. - await recordDisplacement(page, true); - await page.waitForTimeout(150); + await page.waitForFunction(() => window.__makaTranscriptDisplacement?.isSettled()); return turns.first().getAttribute('data-turn-id'); }) .not.toBe(firstBefore); pages += 1; mountedMax = Math.max(mountedMax, await turns.count()); + // Let the probe compare the changed range with its next rendered frame + // before another wheel closes the measurement interval. + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); } expect(pages).toBeGreaterThan(0); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8918f334e9..c11645d847 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -329,7 +329,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 3648 + "nonTriviaTokens": 3646 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -496,7 +496,7 @@ "react": 1 }, "importSpecifiers": 19, - "nonTriviaTokens": 3736 + "nonTriviaTokens": 3718 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 7, @@ -729,8 +729,6 @@ "window.maka.settings.subscribeClientChanged": 1 }, "environmentCapabilities": { - "document.querySelector": 1, - "requestAnimationFrame": 1, "window.clearTimeout": 1, "window.requestAnimationFrame": 4, "window.setTimeout": 1 @@ -865,7 +863,7 @@ "react": 1 }, "importSpecifiers": 104, - "nonTriviaTokens": 13531 + "nonTriviaTokens": 13459 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -930,23 +928,23 @@ "nonTriviaTokens": 135 }, "src/renderer/use-app-shell-session-workspace.ts": { - "importDeclarations": 8, + "importDeclarations": 7, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { "useAppShellSessionList": 1, "useAppShellSessionUiState": 1, "useExternalStoreSelector": 1, - "useRef": 7, + "useRef": 5, "useSessionCatalogController": 1, - "useState": 3 + "useState": 2 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./app-shell-session-ui-state.js": 1, "./bootstrap-selection-lease.js": 1, + "./features/conversation/index.js": 1, "./new-task-reload-intent.js": 1, "./session-catalog-state.js": 1, "./session-workspace-actions.js": 1, @@ -954,8 +952,8 @@ "./use-external-store-selector.js": 1, "react": 1 }, - "importSpecifiers": 10, - "nonTriviaTokens": 466 + "importSpecifiers": 9, + "nonTriviaTokens": 464 } }, "closure": { diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index 8dc33c60bb..cc9cc16198 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -93,11 +93,11 @@ export function createActionsDeps() { }, setActiveId: () => undefined, setMessageLoadErrorBySession: () => undefined, - setMessages: () => undefined, addTransientMessage: () => undefined, updateTransientMessage: () => undefined, removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, + isMessagePublished: (_message: unknown) => false, setInteractionBySession: () => undefined, respondToUserForm: async () => undefined, showModelSetupToast: () => undefined, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 915d08a672..8af560d34d 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -35,6 +35,10 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import { act, createElement } from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; @@ -543,6 +547,72 @@ describe('composer first-send cleanup', () => { } }); + it('refresh waits for durable messages without bypassing range publication', async () => { + const deps = createActionsDeps(); + deps.activeIdRef.current = 'session'; + let durable = false; + const durableAnswer = { id: 'answer' }; + let publishedAnswer = { id: 'answer' }; + const ready = deferred(); + const controller = { + ready: () => ready.promise, + waitForDurableMessage: async () => { durable = true; return true; }, + store: { + snapshot: () => ({ sessionId: 'session', messages: [durableAnswer] }), + hasDurableMessage: () => durable, + }, + } as unknown as DesktopTranscriptRangeController; + const dependencies = { + ...deps, + transcriptRangeRef: { current: controller }, + isMessagePublished: (message: unknown) => message === publishedAnswer, + }; + const actions = createAppShellChatActions(dependencies); + const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); + assert.equal(durable, false); + ready.resolve(); + assert.equal(await refresh, false, 'durability cannot retire the live answer before publication'); + publishedAnswer = durableAnswer; + assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true); + }); + + it('an in-flight refresh reads publication that commits after the call began', async () => { + const deps = createActionsDeps(); + deps.activeIdRef.current = 'session'; + const answer = { type: 'assistant', id: 'answer', text: 'done', ts: 1 } as StoredMessage; + const ready = deferred(); + const controller = { + ready: () => ready.promise, + store: { + snapshot: () => ({ sessionId: 'session', messages: [answer] }), + hasDurableMessage: () => true, + }, + } as unknown as DesktopTranscriptRangeController; + const { root } = installReactRenderer(); + let publication!: ReturnType['publication']; + function Probe(): null { + publication = useAppShellSessionUiState(deps.activeIdRef, () => {}).publication; + return null; + } + try { + act(() => root.render(createElement(Probe))); + const actions = createAppShellChatActions({ + ...deps, transcriptRangeRef: { current: controller }, + isMessagePublished: publication.isMessagePublished, + }); + const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); + act(() => { + publication.messagesRef.current = [answer]; + publication.setMessagesState([answer]); + }); + ready.resolve(); + assert.equal(await refresh, true, 'the original invocation must see the new publication'); + assert.equal(publication.isMessagePublished({ ...answer }), false, 'same id is not the published version'); + } finally { + cleanupFakeDom(); + } + }); + for (const initialized of [false, true]) { it(`does not navigate the previous Session controller (${initialized ? 'initialized' : 'opening'}) while sending`, async () => { const submissions: string[] = []; @@ -577,7 +647,6 @@ describe('composer first-send cleanup', () => { cancel: () => {}, followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); }, }), - setMessages: () => { assert.fail('the previous range must not replace selected messages'); }, }).send('hello'); assert.equal(result, true); assert.deepEqual(submissions, ['selected-session']); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 86b33fd16c..689312c150 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -243,6 +243,37 @@ test('WorkHub shows the submitted prompt before admission and keeps it until its h.latestRead.resolve(); }); +test('WorkHub holds transcript and live handoff together until publication is admitted', async () => { + const h = await mountController(); + let sent!: Promise; + await act(() => { sent = h.controller.send('held prompt', []); }); + const turnId = h.requests[0]!.turnId; + await act(async () => { h.admission.resolve({ turnId }); await sent; }); + await act(() => h.emit({ type: 'text_delta', id: 'delta', turnId, messageId: 'answer', ts: 1, text: 'Answer' })); + let held = true; + let idle!: () => void; + const detach = h.controller.viewportNavigation.attachCommitScheduler(h.sessionId, { + commitIfIdle(commit) { if (held) return false; commit(); return true; }, + subscribeToIdle(listener) { idle = listener; return () => {}; }, + }); + const messages: StoredMessage[] = [ + { type: 'user', id: 'user', turnId, text: 'held prompt', ts: 1 }, + { type: 'assistant', id: 'answer', turnId, text: 'Answer', ts: 2, modelId: 'fixture' }, + ]; + await act(() => h.publish(messages)); + await act(() => h.emit({ type: 'complete', id: 'done', turnId, ts: 3, stopReason: 'end_turn' })); + await act(() => h.controller.streamingSettled('answer')); + assert.equal(h.controller.transcript.messages.length, 0); + assert.equal(h.controller.transientMessages.length, 1); + assert.ok(h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); + await act(() => { held = false; idle(); }); + assert.deepEqual(h.controller.transcript.messages, messages); + assert.equal(h.controller.transientMessages.length, 0); + assert.ok(!h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); + detach(); + h.latestRead.resolve(); +}); + test('WorkHub removes a failed submission from the conversation and preserves its retry identity', async () => { const h = await mountController(); let sent!: Promise; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 532d4ccbb3..bee65df2f6 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -18,13 +18,13 @@ */ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { StoredMessage } from '@maka/core/session'; import type { CollaborationMode } from '@maka/core/collaboration'; import type * as DesktopBridge from '../preload/bridge-contract.js'; import type { QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; -import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UiLocale } from '@maka/core/ui-locale'; @@ -59,7 +59,6 @@ import { noRealConnectionSetupDescription, } from './model-connection-errors.js'; import type { RefreshMessagesOptions } from './session-message-settlement.js'; -import type { MessageListUpdater } from './session-workspace-actions.js'; export type { RefreshMessagesOptions }; @@ -157,7 +156,6 @@ export function createAppShellChatActions(deps: { activateSessionForFirstSend: (sessionId: string) => Promise; setActiveId: (sessionId: string | undefined) => void; setMessageLoadErrorBySession: MessageLoadErrorUpdater; - setMessages: MessageListUpdater; addTransientMessage: ( sessionId: string, message: TransientUserMessageProjection, @@ -168,6 +166,7 @@ export function createAppShellChatActions(deps: { ) => void; removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; + isMessagePublished: (message: StoredMessage) => boolean; onFollowLatest: (sessionId: string) => Promise; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ @@ -212,7 +211,6 @@ export function createAppShellChatActions(deps: { activateSessionForFirstSend, setActiveId, setMessageLoadErrorBySession, - setMessages, removeTransientMessage, transcriptRangeRef, onFollowLatest, @@ -610,18 +608,21 @@ export function createAppShellChatActions(deps: { if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== controller) { return false; } - const range = controller.store; - const snapshot = range.snapshot(); + const snapshot = controller.store.snapshot(); if (snapshot.sessionId !== sessionId) return false; - const next = [...snapshot.messages]; - setMessages(next); + // Store changes already publish through its active subscription. A + // refresh checks readiness; it must not bypass input-held publication. setMessageLoadErrorBySession((current) => { if (!current[sessionId]) return current; const updated = { ...current }; delete updated[sessionId]; return updated; }); - return requiredMessageId === undefined || range.hasDurableMessage(requiredMessageId); + // The live answer stays visible until the durable answer reaches the + // published view. Its existing publication effect retries this handoff. + return requiredMessageId === undefined || snapshot.messages.some( + (message) => message.id === requiredMessageId && deps.isMessagePublished(message), + ); } catch (error) { if (activeIdRef.current === sessionId) { const message = messageRefreshErrorMessage(error, uiLocale); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 2d295c6f53..15bd1e236c 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -20,7 +20,7 @@ import { useEffect, useEffectEvent, useLayoutEffect } from 'react'; import { useHotkeys } from '@astryxdesign/core/hooks'; import type { ConnectionEvent } from '@maka/core/connections'; -import type { SessionChangedEvent, SessionSummary, StoredMessage } from '@maka/core/session'; +import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import type { ThemePalette, ThemePreference } from '@maka/core/settings'; @@ -319,7 +319,7 @@ export function useActiveSessionEvents(options: { completeObservationSeed: (sessionId: string) => void; setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; setMessageLoadPending: (pending: boolean) => void; - setMessages: (messages: StoredMessage[]) => void; + publishTranscript: (sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, onReady: () => void) => void; transcriptRangeRef: RefBox; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: Pick; @@ -333,20 +333,15 @@ export function useActiveSessionEvents(options: { return next; }); }); - // Reached only from the store subscription, which the effect unsubscribes on - // teardown, so the window it publishes is always a live one. + // Publication rechecks the controller's store identity after any input wait. const applyTranscript = useEffectEvent(( sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, ) => { - if (options.activeIdRef.current === sessionId) { - const snapshot = store.snapshot(); - options.setMessages([...snapshot.messages]); - if (snapshot.ready) { - clearMessageLoadError(sessionId); - options.setMessageLoadPending(false); - } - } + options.publishTranscript(sessionId, store, () => { + clearMessageLoadError(sessionId); + options.setMessageLoadPending(false); + }); }); const applyReadError = useEffectEvent((sessionId: string, error: unknown) => { if (options.activeIdRef.current === sessionId) { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 84ee566e35..db65839b2e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -325,6 +325,9 @@ function AppShellContent({ retireCancelledTransientMessages, removeTransientMessage, transcriptRangeRef, + publishedTranscriptRange, + publishTranscript, + isMessagePublished, messageLoadPending, setMessageLoadPending, sessionUiController, @@ -893,32 +896,6 @@ function AppShellContent({ uiLocale, }); - // PR109e-e: click handler for lineage badge → scroll target turn into - // view. Avoids pulling a separate ref-tracker: relies on the - // `data-turn-id` attribute the renderer already sets on each TurnView. - // - // @kenji PR109e review + @xuan PR109f follow-up: scrollIntoView with - // `behavior: 'smooth'` must respect both reduced-motion AND the - // e2e-fixture capture entry (PR-IR-02). @xuan confirmed on main that - // e2e-fixture always writes `data-maka-e2e-fixture="true"` but - // `data-maka-reduced-motion="true"` is only set on the reduced - // variant — so the e2e-fixture attribute is the broader signal for - // "deterministic capture, no animations". Three triggers collapse to - // `auto`: - // 1. `data-maka-reduced-motion="true"` — PR-IR-04 reduced variant - // 2. `data-maka-e2e-fixture="true"` — PR-IR-02 any capture - // 3. `prefers-reduced-motion: reduce` — OS-level user preference - function handleLineageBadgeClick(targetTurnId: string) { - requestAnimationFrame(() => { - const el = document.querySelector(`[data-turn-id="${CSS.escape(targetTurnId)}"]`); - if (!el || !('scrollIntoView' in el)) return; - (el as HTMLElement).scrollIntoView({ - behavior: readScrollMotionBehavior(), - block: 'center', - }); - }); - } - const openSessionInChatRef = useRef< (sessionId: string, turnId?: string, sequence?: number) => void >(() => undefined); @@ -1490,12 +1467,12 @@ function AppShellContent({ activateSessionForFirstSend, setActiveId, setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, - setMessages, addTransientMessage, updateTransientMessage, removeTransientMessage, transcriptRangeRef, onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? Promise.resolve(true), + isMessagePublished, setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -2010,6 +1987,7 @@ function AppShellContent({ activeSession?.profileId, ); useActiveSessionEvents({ + publishTranscript, uiLocale, activeId: activeHostSession?.id, observationAuthorityRevision: observationAuthorityRef.current.revision, @@ -2020,7 +1998,6 @@ function AppShellContent({ completeObservationSeed, setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, setMessageLoadPending, - setMessages, transcriptRangeRef, setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, @@ -2184,10 +2161,8 @@ function AppShellContent({ const activeUnavailableTranscriptRestore = activeId ? transcriptRestoreUnavailableBySession[activeId] : undefined; - const activeTranscriptRange = Conversation.transcriptReadingPosition.currentRange( - transcriptRangeRef.current, - activeId, - ); + const activeTranscriptRange = publishedTranscriptRange?.sessionId === activeId + ? publishedTranscriptRange : undefined; const homeSurfaceActive = sessionsSelected && messages.length === 0 && @@ -2694,7 +2669,7 @@ function AppShellContent({ detail: resumeParkDescriptionBySession[activeId], onResume: () => { void resumeInterruptedSession(); }, } : undefined} - onLineageBadgeClick={handleLineageBadgeClick} + onLineageBadgeClick={(turnId) => { if (activeId) openSessionInChat(activeId, turnId); }} onReadAttachmentBytes={window.maka.attachments.readBytes} onOpenLinkedSession={openSessionInChat} scrollTargetTurn={ diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts new file mode 100644 index 0000000000..dc22e799df --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useRef, useState } from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import { currentTranscriptRange } from './transcript-reading-position.js'; +import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; + +interface TranscriptSource { + range(): { readonly sessionId: string; readonly hasOlder: boolean; readonly hasNewer: boolean }; + snapshot(): { readonly messages: readonly StoredMessage[]; readonly ready: boolean }; +} + +/** The rendered messages and gap flags are a single publication. The source + * may advance during reader input, but only the scroll authority admits it. */ +export function useAppShellSessionUiState( + activeIdRef: { current: string | undefined }, + publishMessages: (messages: StoredMessage[]) => void, +) { + // The observable controller retains its own identity and subscriptions; + // publication is the React view of the active transcript, not a store copy. + const controllerRef = useRef(null); + controllerRef.current ??= createAppShellSessionUiStateController(); + const controller = controllerRef.current; + const transcriptRangeRef = useRef(undefined); + const messagesRef = useRef([]); + const [view, setView] = useState<{ + messages: StoredMessage[]; + range: ReturnType | undefined; + }>({ messages: [], range: undefined }); + + // These actions capture only lifetime-stable refs, setters and the workspace + // callback that dispatches through its actions ref. Keep their identities as + // stable as the other workspace actions consumers receive. + const [actions] = useState(() => ({ + isMessagePublished: (message: StoredMessage) => messagesRef.current.includes(message), + setMessagesState(messages: StoredMessage[]) { + setView({ + messages, + range: messages.length + ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) + : undefined, + }); + }, + publishTranscript(sessionId: string, store: TranscriptSource, onReady: () => void) { + controller.transcriptViewportNavigation.commitRange(sessionId, () => { + if (transcriptRangeRef.current?.store !== store || activeIdRef.current !== sessionId) return; + const snapshot = store.snapshot(); + publishMessages([...snapshot.messages]); + if (snapshot.ready) onReady(); + }); + }, + })); + + return { + controller, + publication: { + transcriptRangeRef, + messagesRef, + messages: view.messages, + publishedTranscriptRange: view.range, + ...actions, + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 2a7e187bec..36445fb87b 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -45,6 +45,7 @@ export type { ConversationServices } from './ports.js'; export { ConversationServicesProvider } from './services.js'; export { SessionLocalMessages } from './controller/session-local-messages.js'; export { createConversationDisplayFrameScheduler } from './controller/display-frame-scheduler.js'; +export { useAppShellSessionUiState } from './controller/use-app-shell-session-ui-state.js'; export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; export { type PendingAttachment, toComposerIngestItems, retainedAttachmentRefs } from '@maka/ui/composer-attachments'; diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts index d3471401e6..691407f16f 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -17,7 +17,6 @@ * under the License. */ -import { useRef } from 'react'; import type { MessageQueueEntryProjection, ShellRunUpdate } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import { createTranscriptViewportNavigation, type InteractionQueues, type LiveTurnBuffer } from '@maka/ui'; @@ -224,23 +223,6 @@ export function createAppShellSessionUiStateController( export type AppShellSessionUiStateController = ReturnType; -/** - * Owns the controller for the component's lifetime. Deliberately does NOT - * subscribe: readers select what they need through - * `useExternalStoreSelector`, so no single component re-renders for every - * write to the store (#1985). - * - * Returns the controller itself rather than a bag of its members. The bag had - * to name every setter, so did the workspace hook above it, and so did - * AppShell's destructure — three places to edit for one new map, and three - * chances for them to disagree about what the store offers. - */ -export function useAppShellSessionUiState(): AppShellSessionUiStateController { - const controllerRef = useRef(null); - controllerRef.current ??= createAppShellSessionUiStateController(); - return controllerRef.current; -} - function createRuntimeSessionRegistry() { const ref: { current: Record } = { current: {} }; return { diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index d9c6d0df0a..80f9c98bb7 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -63,7 +63,10 @@ export function useWorkHubController() { ); const [choices, setChoices] = useState([]); const [transcript, setTranscript] = useState(emptyTranscript); + // Reconciliation reads the published view, never a source page held by input. const transcriptRef = useRef(emptyTranscript); + // Renderer completion is a one-shot signal; publication may arrive later. + const settledBeforePublication = useRef(new Set()); const [viewportNavigation] = useState(createTranscriptViewportNavigation); const [transientMessages, setTransientMessages] = useState([]); const [messageQueue, setMessageQueue] = useState<{ entries: import('@maka/core/events').MessageQueueEntryProjection[]; revision?: number }>({ entries: [] }); @@ -240,6 +243,7 @@ export function useWorkHubController() { useEffect(() => { setChoices([]); transcriptRef.current = emptyTranscript; + settledBeforePublication.current.clear(); setTranscript(emptyTranscript); setReadError(undefined); const attempt = pendingSend.current; @@ -328,9 +332,6 @@ export function useWorkHubController() { ); const opening = services.openTranscript(sessionId, (snapshot) => { if (disposed) return; - transcriptRef.current = snapshot; - setTranscript(snapshot); - if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); const attempt = pendingSend.current; if (attempt?.sessionId === sessionId) { const messages = snapshot.messages.filter((message) => message.turnId === attempt.input.turnId); @@ -340,13 +341,23 @@ export function useWorkHubController() { const queued = pendingQueued.current; if (queued?.sessionId === sessionId && snapshot.messages.some((message) => message.type === 'user' && message.id === queued.messageId)) queued.observed = true; - setTransientMessages((previous) => previous.filter((pending) => - !snapshot.messages.some((message) => message.type === 'user' && - (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), - )); - setLiveTurns((previous) => - previous ? reconcileLiveTurnBuffer(previous, [...snapshot.messages]) : previous, - ); + viewportNavigation.commitRange(sessionId, () => { + if (disposed) return; + transcriptRef.current = snapshot; + setTranscript(snapshot); + if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); + setTransientMessages((previous) => previous.filter((pending) => + !snapshot.messages.some((message) => message.type === 'user' && + (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), + )); + const settled = snapshot.messages.filter((message) => + message.type === 'assistant' && settledBeforePublication.current.delete(message.id)); + setLiveTurns((previous) => { + let next = previous; + for (const message of settled) if (next) next = settleLiveTurnBufferStep(next, message.id); + return next ? reconcileLiveTurnBuffer(next, snapshot.messages) : next; + }); + }); }, transcriptAbort.signal, readFailed); void opening .then((opened) => { @@ -555,7 +566,11 @@ export function useWorkHubController() { loadLatest: () => range.current?.loadLatest(), report, streamingSettled(messageId?: string) { - if (!messageId || !transcriptRef.current.messages.some((message) => message.id === messageId && message.type === 'assistant')) return; + if (!messageId || currentSessionId.current !== sessionId) return; + if (!transcriptRef.current.messages.some((message) => message.id === messageId && message.type === 'assistant')) { + settledBeforePublication.current.add(messageId); + return; + } setLiveTurns((previous) => { const next = previous ? settleLiveTurnBufferStep(previous, messageId) : undefined; return next ? reconcileLiveTurnBuffer(next, transcriptRef.current.messages) : next; diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 42cf038a26..a2f94be20c 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,12 +42,9 @@ width: 100%; } -/* Inserting earlier turns above the reader must not move what they are - reading. The browser's scroll anchoring does exactly that, so state the - dependency on the scroller that runs it rather than inheriting the `auto` - default: Maka reads no geometry and restores no position of its own. The - one case anchoring declines is a scroller sitting at zero, compensated in - useChatScroll after the turns land. */ +/* Native anchoring handles ordinary content growth. The scroll authority + disables it during following and atomic range replacement, when it owns + the position write itself. */ [data-chat-scroll-container='true'] { overflow-anchor: auto; } @@ -66,8 +63,8 @@ width: 100%; flex-direction: column; gap: var(--spacing-4); - content-visibility: auto; - contain-intrinsic-block-size: auto 280px; + /* Resident Turns contribute their real size before the first scroll. */ + contain: layout style paint; } .maka-chat-message-loading { @@ -137,49 +134,10 @@ gap: var(--space-1); } -/* A transcript range is bounded by complete Turns, so one unusually large - Turn can still be much taller than the scrollport. The outer Turn's - content-visibility boundary stops helping as soon as any part of that Turn - becomes relevant. Keep the stable timeline blocks inside it independently - skippable so Chromium does not lay out and paint every Markdown, reasoning, - and tool subtree while the reader crosses one nearby block. - - Renderers apply the marker at the source of each timeline block, including - the children of a Processing fold. `auto` retains the measured block size - after first paint, preserving native scroll - anchoring when a skipped block leaves and re-enters the viewport. */ +/* Keep containment without skipping layout: first visibility must not replace + an estimate and change the scroll range. Window membership bounds residency. */ .maka-chat-message-list [data-maka-transcript-boundary] { - content-visibility: auto; - /* First-paint intrinsic-size ESTIMATE for scroll-anchor stability, not a - fixed height: `auto px` still grows to the block's real size after - paint. 96px is the single-line answer baseline; tall multi-line blocks - override it below. */ - contain-intrinsic-block-size: auto 96px; -} - -/* A long prompt can put even the first answer beyond the relevance margin. - The streaming frontier must contribute its real height before following; - completed blocks in the same active Turn still retain lazy layout. */ -.maka-chat-message-list [data-maka-transcript-boundary][data-live-streaming='true'] { - content-visibility: visible; -} - -/* Container blocks — a Processing sequence, a linked-agent list — hold many - entries, so their first-paint estimate stays multi-line. It remains an - estimate rather than a clamp: the block grows to its measured size after - paint. */ -.maka-chat-message-list [data-maka-transcript-boundary="large"] { - contain-intrinsic-block-size: auto 320px; -} - -/* Collapsed disclosures are the exception among the "large" sites: a folded - reasoning run or tool/activity card renders as one summary row measuring - 24–32px, and a 320px estimate materializes as a ~290px collapse under a - reader scrolling up cold — the anchor-jump the cold-scroll story gates. - Estimate them at their collapsed height; `auto` still remembers the real - expanded size once a reader opens one. */ -.maka-chat-message-list :is(.maka-deep-thinking, .maka-tool-activity-card)[data-maka-transcript-boundary='large'] { - contain-intrinsic-block-size: auto 32px; + contain: layout style paint; } /* An expanded reasoning header stays reachable while its own detail is being diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index b2ba3134cc..299ecd42a5 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -18,9 +18,8 @@ */ import { useRef, useState } from 'react'; -import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; -import { useAppShellSessionUiState } from './app-shell-session-ui-state.js'; +import * as Conversation from './features/conversation/index.js'; import { selectActiveSessionId, useSessionCatalogController, @@ -48,26 +47,30 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const catalog = useSessionCatalogController(); const activeId = useExternalStoreSelector(catalog, selectActiveSessionId); const activeIdRef = useRef(undefined); - const sessionUiController = useAppShellSessionUiState(); + const actionsRef = useRef(null); + const { controller: sessionUiController, publication } = Conversation.useAppShellSessionUiState( + activeIdRef, + (messages) => actionsRef.current!.setMessages(messages), + ); const sessionList = useAppShellSessionList(toastApi, { catalog, }); const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); - const [messages, setMessages] = useState([]); - const messagesRef = useRef([]); + const { + messagesRef, transcriptRangeRef, setMessagesState, + messages, publishedTranscriptRange, publishTranscript, isMessagePublished, + } = publication; const [transientMessages, setTransientMessages] = useState([]); const transientMessagesBySessionRef = useRef( new Map>(), ); - const transcriptRangeRef = useRef(undefined); const [messageLoadPending, setMessageLoadPending] = useState(false); - const actionsRef = useRef(null); - // Every dep below is a ref box, a state setter, or a method of the - // once-created session-UI controller, so one instance serves the renderer's - // lifetime. Consumers list these in dep arrays and pass them as props; a - // per-render identity there is what defeated the Session rail's memo. + // The captured publication setter only reads stable refs and writes React + // state. Along with the controller methods and other refs, it lets one + // actions instance serve the renderer's lifetime without defeating the + // Session rail's memo with new action identities on every render. actionsRef.current ??= createSessionWorkspaceActions({ activeIdRef, messagesRef, @@ -79,7 +82,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { // controller is created once per renderer, so this identity is fixed and // the once-created factory may capture it. setActiveIdState: catalog.setActiveSessionId, - setMessagesState: setMessages, + setMessagesState, setTransientMessagesState: setTransientMessages, setMessageLoadPending, clearSessionUiState: sessionUiController.clearSessionUiState, @@ -103,6 +106,9 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { bootstrapSelectionLease: bootstrapSelectionLeaseRef.current, ...actions, messages, + publishedTranscriptRange, + publishTranscript, + isMessagePublished, transientMessages, transcriptRangeRef, messageLoadPending, diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 9555a69344..f5fe9688dd 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -963,8 +963,7 @@ export const ManyTurns: Story = { ), }; -// The same transcript and fixture CSS, with enough resident Turns for native -// render skipping but no need to mount the 120-Turn catalog demonstration. +// Exercise transcript motion without mounting the 120-Turn catalog demonstration. export const TranscriptRenderCost: Story = { render: () => , play: async ({ canvasElement }) => { @@ -975,7 +974,6 @@ export const TranscriptRenderCost: Story = { await frame(); let transitions = 0; let animations = 0; - const skipped = new Set(); const turns = [...canvasElement.querySelectorAll('.maka-transcript-turn')]; expect(turns.length).toBeGreaterThan(0); for (const pseudo of [null, '::before', '::after']) { @@ -983,35 +981,24 @@ export const TranscriptRenderCost: Story = { expect(style.transitionProperty).toBe('none'); expect(style.animationName).toBe('none'); } - const visibility = (event: Event) => { - if (event.target !== event.currentTarget) return; - const turn = event.currentTarget as Element; - if ((event as Event & { skipped: boolean }).skipped) skipped.add(turn); - else skipped.delete(turn); - }; - for (const turn of turns) turn.addEventListener('contentvisibilityautostatechange', visibility); const transition = () => { transitions += 1; }; const animation = () => { animations += 1; }; canvasElement.addEventListener('transitionrun', transition, true); canvasElement.addEventListener('animationstart', animation, true); try { // No Host paging or wheel routing is under test: move the real Chromium - // scrollport to exercise the fixture CSS and browser render skipping. - // Two distant positions expose and skip resident Turns. Forty incremental - // paints added cost under CI contention without testing another contract. + // scrollport between two distant positions to exercise the fixture CSS. for (const direction of [-1, 1]) { scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: direction * 120, bubbles: true })); scroller.scrollTop = direction < 0 ? 0 : scroller.scrollHeight; await frame(); await frame(); } - await waitFor(() => expect(skipped.size).toBeGreaterThan(0)); expect(transitions).toBe(0); expect(animations).toBe(0); expect(canvasElement.getAnimations({ subtree: true }) .filter((animation) => animation.playState !== 'finished')).toHaveLength(0); } finally { - for (const turn of turns) turn.removeEventListener('contentvisibilityautostatechange', visibility); canvasElement.removeEventListener('transitionrun', transition, true); canvasElement.removeEventListener('animationstart', animation, true); } @@ -1104,7 +1091,7 @@ export const WideAssistantProse: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const answers = await canvas.findAllByRole('article', { name: 'Maka 的回答' }); + const answers = await canvas.findAllByRole('article', { name: /^Maka 的回答/ }); const answer = answers.at(-1); if (!answer) throw new Error('Wide assistant answer did not render'); const paragraph = await within(answer).findByRole('paragraph'); @@ -1115,7 +1102,6 @@ export const WideAssistantProse: Story = { const turnRect = turn.getBoundingClientRect(); expect(turnRect.width).toBeGreaterThan(680); expect(turnRect.right - paragraph.getBoundingClientRect().right).toBeLessThanOrEqual(1); - expect(getComputedStyle(boundary).contentVisibility).toBe('auto'); // Paint containment (`content-visibility: auto`, `contain: paint`, // `overflow` other than visible) clips to the rounded padding box, and // headless Chromium does not reproduce that clip, so pin the geometry: @@ -2368,6 +2354,7 @@ function SettledTranscriptHarness({ */ function HistoryHarness({ turns }: { turns: number }) { const [range, setRange] = useState({ from: 0, count: turns }); + const [viewportNavigation] = useState(createTranscriptViewportNavigation); useEffect(() => { historyLoads.length = 0; }, []); @@ -2375,14 +2362,15 @@ function HistoryHarness({ turns }: { turns: number }) { -HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, onPrefetchHistory: async (edge) => { if (edge !== 'older') return false; historyLoads.push(firstResidentTurnId() ?? '(none)'); - setRange((current) => ({ + viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ from: current.from - HISTORY_BATCH, count: current.count + HISTORY_BATCH, - })); + }))); await painted(2); return true; }, @@ -2615,10 +2603,41 @@ export const Performance45Tools: Story = { render: () => , }; +// Fixed membership: geometry probes must not mistake history paging for lazy +// layout. Mixed prose and long 100+ line CodeBlocks exercise all three +// skipping boundaries (Turn, timeline block, Astryx line chunk). +export const GeometryMixed24Turns: Story = { + render: () => { + const turnId = `geometry-${i}`; + const prose = Array.from({ length: 4 + (i % 5) * 3 }, (_, p) => + `第 ${i + 1} 轮,第 ${p + 1} 段。${'固定内容用于检查首次上滚时的文档尺寸,不发生流式输出或历史分页。'.repeat(3)}`, + ).join('\n\n'); + const code = i % 6 === 0 + ? '\n\n```text\n' + Array.from({ length: 140 }, (_, line) => + `${line + 1}: ${'wrapped-code-content-'.repeat(9)}`, + ).join('\n') + '\n```' + : ''; + return [user(`geometry-u-${i}`, turnId, 50 - i, `检查第 ${i + 1} 组。`), + assistant(`geometry-a-${i}`, turnId, 50 - i, prose + code)]; + }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, +}; + +export const GeometryLongCode: Story = { + render: () => + `${line + 1}: ${'wrapped-code-content-'.repeat(9)}`, + ).join('\n') + '\n```'), + ], hasOlderHistory: false, hasNewerHistory: false }} />, +}; + export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { render: () => , play: async () => { const root = tailScroller(); + await document.fonts.ready; + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); // A single Turn taller than several viewports is the point; without the // overflow the rest proves nothing. @@ -2626,14 +2645,6 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { root.scrollHeight, JSON.stringify(tailMetrics()), ).toBeGreaterThan(root.clientHeight * 3); - // The containment claim itself, in the same Chromium the app ships: - // offscreen timeline blocks are genuinely skipped, not merely marked. - // (This carries the deleted Electron spec's assertion — #4825 moved this - // tier of coverage below Electron.) - const skipped = [...root.querySelectorAll('[data-maka-transcript-boundary]')] - .filter((element) => !element.checkVisibility({ contentVisibilityAuto: true })) - .length; - expect(skipped, 'no offscreen boundary is skipped').toBeGreaterThan(0); // The visible block nearest the middle of the scrollport, re-chosen each // step so it is always one the reader can actually see. @@ -2655,15 +2666,8 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { return anchor; }; - // Cold: no warmup pass has rendered the blocks above, so each upward step - // materializes first-paint intrinsic-size estimates. The criterion is what - // the reader sees, so it is measured in viewport space: an anchor they were - // reading should move down by exactly the step they asked for. Native - // `overflow-anchor` compensates the materialization by adjusting - // `scrollTop`, so neither document-space growth nor the scrollTop delta may - // be the yardstick — comparing against either reports the (allowed) - // correction itself as a jump. Only `|viewport move − intended step|` is a - // jump the reader experiences. + // First traversal, without a preparatory scroll. The visible block must + // move by the requested distance, without an extra layout correction. let worstUnexpected = 0; const steps: Array> = []; for (let step = 0; step < 8 && root.scrollTop > 0; step += 1) { @@ -2687,19 +2691,12 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { grewBy: root.scrollHeight - heightBefore, }); } - // On main this story reads 0 by construction — no sub-turn boundary exists - // to materialize. On this branch the error tracks materialization exactly: - // a zero-growth step read 0px, and with the folded-disclosure estimate at - // 320px against a 24–32px collapsed row, steps measured up to 244px — a - // reader-visible stall of a 240px scroll step. With the collapsed estimate - // corrected, the residual is the answer blocks' estimate error, which stays - // well under half a step. The bound is half a step: loose enough for - // per-run variance, tight enough that a stalled or reversed step can never - // pass again. + // Fixed content must move only by the requested distance, including on + // the first traversal. One CSS pixel allows rounding, not an estimate. expect( worstUnexpected, `worst unexpected reading-anchor move: ${Math.round(worstUnexpected)}px; steps: ${JSON.stringify(steps)}`, - ).toBeLessThanOrEqual(120); + ).toBeLessThanOrEqual(1); }, }; @@ -2708,85 +2705,12 @@ export const OversizedLiveTurnHoldsAReadingAnchorOnColdScroll: Story = { render: () => , }; -export const EarlierHistoryLandsAboveTheReader: Story = { - render: () => , - play: async () => { - const root = tailScroller(); - await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); - - // Just short of the band that asks for more, so the active range has - // painted turns around the reader before the load starts. Landing straight - // on zero leaves no visible turn above the load boundary to anchor on. - scrollAsReader(root, loadBand() + 400); - await painted(6); - const before = firstResidentTurnId(); - const heightBefore = root.scrollHeight; - historyLoads.length = 0; - - // The move that asks for earlier history and the reading of where the - // reader is, in one task. - scrollAsReader(root, Math.min(300, root.scrollHeight - root.clientHeight)); - const rootTop = root.getBoundingClientRect().top; - const turn = [...root.querySelectorAll('[data-turn-id]')].find( - (candidate) => candidate.getBoundingClientRect().bottom > rootTop, - ); - if (!turn?.dataset.turnId) throw new Error('no turn is on screen'); - const anchor = { turnId: turn.dataset.turnId, top: Math.round(turn.getBoundingClientRect().top) }; - wheelUp(root); - - await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); - await painted(6); - - // The turns that arrived went above the reader, and the reader did not go - // with them. Asserting the element rather than a `scrollTop` delta is the - // point: a compensation computed from `scrollHeight` satisfies the delta - // while putting the reader somewhere else entirely. - // - // Budgeted against what arrived rather than in fixed pixels. A Turn carries - // `content-visibility: auto`, so one that lands off screen is anchored - // against its estimated height and settles a few pixels away from it; a - // reader who went with the history instead moves by the whole insert. - await waitFor(() => - expect( - tailScroller().scrollHeight - heightBefore, - JSON.stringify({ anchor, loads: historyLoads }), - ).toBeGreaterThan(400), - ); - - // Fixed once, after the arrival has settled. Recomputed on every retry it - // would grow along with the drift it is supposed to bound, so a late - // `content-visibility` resolution could admit a reading that was failing. - await painted(8); - const inserted = tailScroller().scrollHeight - heightBefore; - const budget = Math.max(4, inserted * 0.02); - expect( - Math.abs(turnTop(anchor.turnId) - anchor.top), - JSON.stringify({ anchor, inserted, budget, now: turnTop(anchor.turnId), ...tailMetrics() }), - ).toBeLessThanOrEqual(budget); - }, -}; - /** - * The reader going *up* through Turns that have never rendered. - * - * A bound, not stillness. A Turn off screen is laid out at - * `contain-intrinsic-block-size: auto 280px` and swaps to its real height on - * the way past, so travelling through them moves things by construction — - * about 8% of the transcript here. What the bound says is that one Turn owes - * at most one estimate, keeping the correction proportional to Turns crossed - * rather than to what is inside them. + * First upward traversal of a deep fixed transcript: document height and + * the reader's content position must remain stable without a warm-up pass. */ const TRAVERSAL_STEP = 700; -/** What one Turn is worth, measured after everything has rendered once. */ -function medianTurnHeight(): number { - const heights = [...tailScroller().querySelectorAll('[data-turn-id]')] - .map((turn) => turn.getBoundingClientRect().height) - .sort((a, b) => a - b); - if (heights.length === 0) throw new Error('the transcript has no mounted turn'); - return heights[Math.floor(heights.length / 2)]; -} - /** The first Turn whose box is still on screen, and where it starts. */ function anchorInView(): { turnId: string; top: number } { const root = tailScroller(); @@ -2802,6 +2726,8 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { render: () => , play: async () => { const root = tailScroller(); + await document.fonts.ready; + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); const heightBefore = root.scrollHeight; expect( @@ -2827,20 +2753,15 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { expect(steps, 'the traversal has to have taken real steps').toBeGreaterThan(6); const worstDrift = Math.max(...drifts.map(Math.abs)); - const turnHeight = medianTurnHeight(); - // No single step throws the reader past a whole exchange. One Turn's worth - // of correction is the most one Turn can owe. - expect(worstDrift, `per-step drift: ${drifts.join(' ')} against a Turn of ${turnHeight}`) - .toBeLessThanOrEqual(turnHeight); - - // And over the whole traversal the corrections stay proportional to the - // Turns crossed. Measured at ~8% here; #4259's 63% is the failure this - // exists to catch. + expect(worstDrift, `per-step drift: ${drifts.join(' ')}`) + .toBeLessThanOrEqual(1); + + // The fixed document keeps its full height throughout the traversal. const heightAfter = root.scrollHeight; expect( - Math.abs(heightAfter - heightBefore) / heightBefore, - JSON.stringify({ heightBefore, heightAfter, steps, turnHeight }), - ).toBeLessThanOrEqual(0.15); + Math.abs(heightAfter - heightBefore), + JSON.stringify({ heightBefore, heightAfter, steps }), + ).toBeLessThanOrEqual(1); // And the reader can still get back. dockButton().click(); @@ -2858,6 +2779,10 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { render: () => , play: async () => { const root = tailScroller(); + // Measure history publication against rendered content, not the cold + // Markdown module's temporary plain-text layout. + await document.fonts.ready; + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); // Writing zero while the scroller is still at zero is a no-op, so require // the initial pin to have provably moved before exercising the real one. await waitFor(() => { @@ -2870,15 +2795,14 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { // The one position where the browser declines to anchor, and the one the // wheel-to-load path puts the reader in. scrollAsReader(root, 0); + const reading = anchorInView(); wheelUp(root); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await painted(6); - // Anchoring resumes at an offset of one pixel, so the offset itself is the - // evidence: left at zero the browser holds the scroller at the top and - // every turn that arrives pushes the reader's content down the viewport. - expect(tailScroller().scrollTop).toBeGreaterThanOrEqual(1); + expect(Math.abs(turnTop(reading.turnId) - reading.top)).toBeLessThanOrEqual(1); }, }; diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index fb734db38a..ee62e1657e 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -24,7 +24,7 @@ import { afterEach, test } from 'node:test'; import { act, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { TurnView } from '../chat-turn.js'; +import { LocalizedChatMessage, TurnView } from '../chat-turn.js'; import { LocaleProvider } from '../locale-context.js'; import type { TurnTimelineItem, TurnViewModel } from '../materialize.js'; @@ -109,6 +109,15 @@ const RUNNING_TOOL: TurnTimelineItem = { items: [{ toolUseId: 'tool-1', toolName: 'read', status: 'running', args: {} }], }; +test('message accessibility labels preserve literal ICU syntax', async () => { + const { container, root } = domRoot(); + const label = "Maka's response · {value} it's literal"; + await act(() => { + root.render({null}); + }); + assert.equal(container.querySelector('article')?.getAttribute('aria-label'), label); +}); + test('renders an aborted turn outcome as an inline system status notice', async () => { const { container, root } = domRoot(); await renderTurn(root, { @@ -294,6 +303,10 @@ test('uses human conversation context instead of raw ids in action names', async const actionNames = [...container.querySelectorAll('[aria-label]')] .map((element) => element.getAttribute('aria-label')) .filter((label): label is string => label !== null); + assert.match( + container.querySelector('.maka-assistant-answer')?.getAttribute('aria-label') ?? '', + /^Maka's response · Summarize the accessibility findings/, + ); assert.ok(actionNames.some((label) => label.startsWith( 'Copy message: Summarize the accessibility findings', ))); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 7170af5307..988c3961eb 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -22,6 +22,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; +import { createTranscriptViewportNavigation } from '../transcript-viewport-navigation.js'; interface FakeTurn { turnId: string; @@ -46,6 +47,7 @@ interface FakeRoot { removeEventListener(type: string, listener: (event: unknown) => void): void; input(deltaY: number, modifiers?: { ctrlKey?: boolean; metaKey?: boolean }): void; grabScrollbar(): void; + touch(type: 'touchstart' | 'touchend' | 'touchcancel', count: number): void; end(): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; @@ -90,6 +92,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F emit('pointerdown', { button: 0, pointerType: 'mouse', pointerId: 1, target: proxy }); }, end() { emit('scrollend'); }, + touch(type, count) { emit(type, { touches: Array.from({ length: count }, () => ({ clientY: 100 })) }); }, grow(by) { root.scrollHeight += by; }, @@ -183,6 +186,72 @@ test('Ctrl and Meta wheel zoom preserve following without requesting history', ( }); }); +test('touch publication waits for the last contact to end or cancel', () => { + withObservers(() => { + for (const end of ['touchend', 'touchcancel'] as const) { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + publication.attachCommitScheduler('session', authority); + let commits = 0; + root.touch('touchstart', 1); + root.touch('touchstart', 2); + publication.commitRange('session', () => commits++); + assert.equal(commits, 0); + root.touch(end, 1); + assert.equal(commits, 0, 'remaining contact still holds publication'); + root.touch(end, 0); + assert.equal(commits, 1, 'last contact releases publication'); + detach(); + } + }); +}); + +test('a held scrollbar coalesces range publication until release, including a stationary hold', () => { + withObservers((_resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + publication.attachCommitScheduler('session', authority); + const commits: number[] = []; + root.grabScrollbar(); + root.scrollTop -= 100; + root.emitScroll(); + publication.commitRange('session', () => commits.push(1)); + publication.commitRange('session', () => commits.push(2)); + root.end(); frame(); frame(); + assert.deepEqual(commits, []); + root.ownerDocument.dispatchEvent(new Event('pointerup')); + frame(); frame(); + assert.deepEqual(commits, [2]); + }); +}); + +test('an edge wheel without scrollend publishes after input settles', () => { + withObservers((_resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + const detachPublication = publication.attachCommitScheduler('session', authority); + root.scrollTop = 0; + let commits = 0; + const phases: string[] = []; + authority.subscribeToReaderScroll((phase) => { + phases.push(phase); + if (phase === 'input') publication.commitRange('session', () => commits++); + }); + root.input(-100); + assert.equal(commits, 0); + frame(); frame(); + assert.equal(commits, 1); + assert.deepEqual(phases, ['input', 'settled']); + detach(); detachPublication(); + }); +}); + test('content that grows under a pinned transcript keeps the tail on screen', () => { withObservers((resize) => { const root = fakeRoot(); @@ -279,6 +348,27 @@ test('scrollbar defaults can land after pointerup, while an unmoved click retire }); }); +test('navigation during a held scrollbar still publishes on release or cancellation', async () => { + for (const event of ['pointerup', 'pointercancel']) { + const state = withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + const publication = createTranscriptViewportNavigation(); + publication.attachCommitScheduler('session', authority); + const commits: number[] = []; + root.grabScrollbar(); + publication.commitRange('session', () => commits.push(1)); + authority.releasePin(); + return { root, commits }; + }); + await Promise.resolve(); + assert.deepEqual(state.commits, [], 'navigation must preserve the physical hold'); + withObservers(() => state.root.ownerDocument.dispatchEvent(new Event(event))); + assert.deepEqual(state.commits, [1], 'release wakes the pending publication without another update'); + } +}); + test('explicit navigation cancels input provenance before positioning its target', () => { withObservers(() => { const root = fakeRoot(); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index dffe65421f..d1ca5298dc 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, useRef } from 'react'; +import { act, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import type { StoredMessage } from '@maka/core/session'; @@ -355,10 +355,12 @@ test('a fill that issued no read is not chained into another one', async () => { await act(async () => { transcript.readerScrollTo(0); }); - assert.equal(requests, 2, 'the landed read chains one re-check, whose refusal ends it'); + assert.equal(requests, 1, 'the landed read waits for input settlement'); + await act(() => transcript.scroller.dispatchEvent(new window.Event('scrollend'))); + assert.equal(requests, 2, 'settlement rechecks the published range, whose refusal ends it'); }); -test('an older request at offset zero restores the browser anchoring the reader depends on', async () => { +test('an older request at offset zero does not move the reader', async () => { const { document, window } = parseHTML( '
', ); @@ -391,7 +393,198 @@ test('an older request at offset zero restores the browser anchoring the reader transcript.readerScrollTo(0); assert.equal(requests, 1); - assert.equal(transcript.scrollTop, 1, 'keep native anchoring enabled at the start'); + assert.equal(transcript.scrollTop, 0, 'publication owns anchoring; input must not nudge the reader'); +}); + +test('idle range admission commits the React DOM before a subsequent input can begin', async () => { + const navigation = createTranscriptViewportNavigation(); + const { document, window } = parseHTML('
'); + const { frames } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 400, turnHeight: 400, turnCount: 12, + }); + let authority!: TranscriptScrollAuthority; + let publish!: (value: string) => void; + function Harness() { + const [value, setValue] = useState('old'); + publish = setValue; + authority = useTranscriptScrollAuthority(); + const scrollRef = useRef(transcript.scroller); + useChatScroll({ scrollRef, sessionId: 'admission', messages: [], behavior: 'auto', viewportNavigation: navigation }); + return {value}; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await act(async () => { + navigation.commitRange('admission', () => publish('new')); + await Promise.resolve(); + assert.equal(document.querySelector('#mount')!.textContent, 'new', + 'an admitted update must not remain in React scheduling after its idle check'); + }); + await act(async () => { + navigation.commitRange('admission', () => publish('held')); + const down = new window.Event('pointerdown'); + Object.defineProperties(down, { + button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, + }); + transcript.scroller.dispatchEvent(down); + await Promise.resolve(); + assert.equal(document.querySelector('#mount')!.textContent, 'new', + 'input that starts before admission must hold the queued update'); + }); + await act(() => document.dispatchEvent(new window.Event('pointerup'))); + await act(() => { + const pending = [...frames.values()]; frames.clear(); + for (const callback of pending) callback(0); + }); + assert.equal(document.querySelector('#mount')!.textContent, 'held'); +}); + +test('a source publication survives viewport unmount without another source update', async () => { + const { document, window } = parseHTML('
'); + installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { clientHeight: 400, turnHeight: 400, turnCount: 12 }); + const navigation = createTranscriptViewportNavigation(); + let publish!: (value: string) => void; + let show!: (value: boolean) => void; + function Surface() { + const scrollRef = useRef(transcript.scroller); + useChatScroll({ scrollRef, sessionId: 'session', messages: [], behavior: 'auto', viewportNavigation: navigation }); + return null; + } + function Harness() { + const [value, setValue] = useState('old'); + const [visible, setVisible] = useState(true); + publish = setValue; show = setVisible; + return <>{value}{visible && }; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await act(() => wheel(transcript.scroller, -100)); + await act(() => navigation.commitRange('session', () => publish('latest'))); + assert.equal(document.querySelector('#mount')!.textContent, 'old'); + await act(() => show(false)); + assert.equal(document.querySelector('#mount')!.textContent, 'latest'); + await act(() => show(true)); + assert.equal(document.querySelector('#mount')!.textContent, 'latest'); +}); + +for (const hasOlder of [false, true]) { + test(`stationary upward input ${hasOlder ? 'reads available history' : 'keeps following without history'}`, async () => { + const navigation = createTranscriptViewportNavigation(); + const { document, window } = parseHTML('
'); + const { frames, deliverResizeOf } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 400, turnHeight: 200, turnCount: 1, + }); + let authority!: TranscriptScrollAuthority; + let requests = 0; + function Harness() { + authority = useTranscriptScrollAuthority(); + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, sessionId: 'short', messages: [], behavior: 'auto', viewportNavigation: navigation, + hasOlderHistory: hasOlder, + onPrefetchHistory: () => { requests++; return new Promise(() => {}); }, + }); + return null; + } + const frame = async () => act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await frame(); // Initial fill may already be in flight when the reader asks. + await act(() => wheel(transcript.scroller, -100)); + let publications = 0; + await act(() => navigation.commitRange('short', () => { + publications++; + transcript.setTurnCount(3); + if (hasOlder) { + [...transcript.scroller.children].forEach((turn, index) => { + (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; + }); + } + })); + assert.equal(publications, 0, 'input holds publication, including an accepted history request'); + await frame(); await frame(); + assert.equal(requests, hasOlder ? 1 : 0); + assert.equal(publications, 1); + assert.equal(authority.getSnapshot().pinned, !hasOlder); + const beforeGrowth = transcript.scrollTop; + await act(() => { + transcript.setTurnCount(4); + deliverResizeOf(transcript.scroller); + }); + assert.equal(transcript.scrollTop, hasOlder ? beforeGrowth : 400); + }); +} + +test('a held fill publishes before trimming or chaining from the new geometry', async () => { + const navigation = createTranscriptViewportNavigation(); + const { document, window } = parseHTML('
'); + const { frames } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 400, turnHeight: 400, turnCount: 12, + }); + let authority!: TranscriptScrollAuthority; + let finishRead!: () => void; + let requests = 0; + let publications = 0; + const retained: string[] = []; + function Harness() { + authority = useTranscriptScrollAuthority(); + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, sessionId: 'held-fill', messages: [], behavior: 'auto', viewportNavigation: navigation, + hasOlderHistory: true, + onPrefetchHistory: () => { + requests++; + return new Promise((resolve) => { + finishRead = () => { + navigation.commitRange('held-fill', () => { + publications++; + transcript.setTurnCount(14); + [...transcript.scroller.children].forEach((turn, index) => { + (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; + }); + }); + resolve(true); + }; + }); + }, + onRetainWindow: (range) => { retained.push(range.firstTurnId); }, + }); + return null; + } + const frame = async () => act(() => { + const pending = [...frames.values()]; frames.clear(); + for (const callback of pending) callback(0); + }); + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render()); + await frame(); + retained.length = 0; + await act(() => { + const down = new window.Event('pointerdown'); + Object.defineProperties(down, { + button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, + }); + transcript.scroller.dispatchEvent(down); + transcript.scroller.scrollTop = 0; + transcript.scroller.dispatchEvent(new window.Event('scroll')); + }); + assert.equal(requests, 1); + await act(() => finishRead()); + assert.equal(publications, 0); + assert.deepEqual(retained, [], 'published IDs cannot trim source while its new page is held'); + assert.equal(requests, 1, 'a held response must not chain reads using stale geometry'); + await act(() => document.dispatchEvent(new window.Event('pointerup'))); + await frame(); await frame(); + assert.equal(publications, 1); + assert.equal(retained.at(-1), 'turn--2', 'the new published band includes the older page'); }); test('a transcript change re-reads the band while the reader stays at the tail', async () => { @@ -485,6 +678,8 @@ test('the retained window is the band around the reader, and an unmounted bookma retained.length = 0; transcript.readerScrollTo(6_000); + assert.deepEqual(retained, [], 'trim waits for the input to finish'); + transcript.scroller.dispatchEvent(new window.Event('scrollend')); assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-5', lastTurnId: 'turn-15' }); // Six screens is the threshold: with less than that beyond the scrollport in @@ -570,6 +765,7 @@ test('a viewport that shrinks trims what it just pushed beyond the band', async transcript.readerScrollTo(4_000); retained.length = 0; transcript.readerScrollTo(4_100); + transcript.scroller.dispatchEvent(new window.Event('scrollend')); assert.deepEqual(retained, [], 'nothing lies six 1000px screens away'); transcript.setClientHeight(400); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index b1717de092..85ed5d091d 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -83,7 +83,9 @@ export function LocalizedChatMessage({ accessibleLabel: string; }) { const overrides = useMemo( - () => ({ '@astryx.chatMessage.messageFrom': accessibleLabel }), + // This is already formatted text, not an ICU template. Quote from the + // first syntax character onward; ICU only opens a quote before syntax. + () => ({ '@astryx.chatMessage.messageFrom': accessibleLabel.replace(/'/g, "''").replace(/[{}<>].*$/s, "'$&'") }), [accessibleLabel], ); return ( @@ -461,6 +463,11 @@ export const TurnView = memo(function TurnView(props: { const { turn } = props; const forwardBadges = props.lineageBadges?.filter((b) => b.direction === 'forward') ?? []; const reverseBadges = props.lineageBadges?.filter((b) => b.direction === 'reverse') ?? []; + const answerContext = accessibleActionContext( + turn.user?.text ?? finalAssistantReplyText(turn) ?? '', + turn.startedAt, + locale, + ); // A recorded conversational terminal turn owns presentation beyond its // timeline: failure/abort state and recovery actions must remain visible even // when the provider produced no assistant event. Inferred legacy turns and @@ -651,7 +658,7 @@ export const TurnView = memo(function TurnView(props: { return ( ) : undefined} - context={accessibleActionContext( - turn.user?.text ?? finalAssistantReplyText(turn) ?? '', - turn.startedAt, - locale, - )} + context={answerContext} onAction={ props.onFooterAction ? (actionId) => props.onFooterAction?.(turn.turnId, actionId) @@ -1167,7 +1170,7 @@ const AssistantAnswerBubble = memo(function AssistantAnswerBubble(props: Assista return ( {entries.map((entry, index) => ( ) : ( @@ -439,7 +439,7 @@ function LinkedAgentList(props: { const activityCopy = getToolActivityCopy(props.locale); const copy = activityCopy.agent; return ( - + {props.rows.map((row) => { const childSessionId = row.childSessionId; const open = childSessionId && props.onOpenLinkedSession diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index d02c51803e..ea214de8df 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -22,7 +22,7 @@ * host; explicit navigation releases this authority before moving the viewport. * * pinned → content that grows writes `scrollTop = scrollHeight` - * !pinned → nothing here writes `scrollTop`, ever + * !pinned → only an explicit range publication restores its reading anchor * * While pinned, disable native anchoring so content cannot move the viewport * behind this authority's own write. Once released, restore native anchoring @@ -41,6 +41,7 @@ import { type ReactNode, } from 'react'; import { ChatLayoutScrollButton } from '@astryxdesign/core/Chat'; +import { flushSync } from 'react-dom'; /** Astryx's own thresholds, so the affordance keeps the feel readers learnt. */ const PIN_THRESHOLD_PX = 10; @@ -60,22 +61,29 @@ export interface TranscriptScrollSnapshot { } export interface TranscriptScrollAuthority { + /** Whether native input still holds the published geometry. */ + isInputActive(): boolean; + /** Synchronously publish and preserve geometry if native input permits it. */ + commitIfIdle(commit: () => void): boolean; + subscribeToIdle(listener: () => void): () => void; /** Take the scroller. Returns the detach for the effect that called it. */ attach(root: HTMLElement | null): () => void; /** One-shot: put the tail back under the reader and follow it again. */ pinToTail(): void; /** * The reader chose a position, so stop following. A command that moves the - * viewport itself calls this first; afterwards nothing here writes, which is - * why a command cannot race the policy. + * viewport itself calls this first; afterwards automatic following is off. */ releasePin(): void; /** * Input can request history at an edge before any movement. Scroll reports - * the resulting reading position. Neither phase is emitted for layout alone; + * the resulting reading position; settled rechecks the final edge after a + * gesture. No phase is emitted for layout alone; * consumers do not interpret raw wheel or scroll events themselves. + * Returning true from input accepts history-reading intent, even at an + * unmoving edge. The window owner knows whether adjacent history exists. */ - subscribeToReaderScroll(listener: (phase: 'input' | 'scroll') => void): () => void; + subscribeToReaderScroll(listener: (phase: 'input' | 'scroll' | 'settled', direction?: 'up' | 'down') => boolean | void): () => void; /** * The reading position, measured now and published like any other move. For * a caller that has just moved the viewport or the content itself and cannot @@ -107,10 +115,39 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // Geometry belongs to a known input operation, never the other way around. // scrollend also covers smooth keyboard scrolling and touchpad inertia. let gesture: { top: number; direction?: 'up' | 'down' } | undefined; + const idleListeners = new Set<() => void>(); + let pointer: number | undefined; + let touchHeld = false; + const isInputActive = (): boolean => gesture !== undefined || pointer !== undefined || touchHeld; + const commitRange = (commit: () => void): void => { + const target = root; + if (!target) { commit(); return; } + if (pinned) { flushSync(commit); writeToTail(); return; } + const top = target.getBoundingClientRect().top; + const anchor = [...target.querySelectorAll('[data-turn-id]')] + .find((turn) => turn.getBoundingClientRect().bottom > top); + if (!anchor) { flushSync(commit); return; } + const before = anchor.getBoundingClientRect().top; + // A gap notice is a poor native anchor: it survives a range replacement + // while the paragraph beneath it moves. Restore a content Turn once, with + // native compensation disabled for the same synchronous publication. + target.style.overflowAnchor = 'none'; + try { + flushSync(commit); + const next = target.querySelector(`[data-turn-id="${CSS.escape(anchor.dataset.turnId!)}"]`); + if (next) target.scrollTop += next.getBoundingClientRect().top - before; + } finally { + target.style.overflowAnchor = pinned ? 'none' : 'auto'; + } + }; + const notifyIdle = (): void => { + if (isInputActive()) return; + for (const listener of [...idleListeners]) listener(); + }; let readingTurnId: string | undefined; let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail, readingTurnId }; const listeners = new Set<() => void>(); - const readerListeners = new Set<(phase: 'input' | 'scroll') => void>(); + const readerListeners = new Set<(phase: 'input' | 'scroll' | 'settled', direction?: 'up' | 'down') => boolean | void>(); const distanceToTail = (): number => root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; const readTurn = (): string | undefined => { @@ -136,11 +173,25 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { awayFromTail = false; publish(); }; - const reportReader = (phase: 'input' | 'scroll'): void => { - for (const listener of [...readerListeners]) listener(phase); + const reportReader = (phase: 'input' | 'scroll' | 'settled', direction?: 'up' | 'down'): boolean => { + let readingHistory = false; + for (const listener of [...readerListeners]) { + if (listener(phase, direction) === true) readingHistory = true; + } + return readingHistory; }; return { + isInputActive, + commitIfIdle(commit) { + if (isInputActive()) return false; + commitRange(commit); + return true; + }, + subscribeToIdle(listener) { + idleListeners.add(listener); + return () => { idleListeners.delete(listener); }; + }, attach(next) { root = next; const target = root; @@ -150,16 +201,20 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const begin = (event: Event, direction: 'up' | 'down'): void => { if (event.defaultPrevented || !reachesTranscript(event, target, direction)) return; const remaining = direction === 'up' ? target.scrollTop : distanceToTail(); + gesture = { top: gesture?.top ?? target.scrollTop, direction }; if (remaining <= 0) { // An edge gesture can ask for an adjacent history page even though // it produces no scroll (and therefore no scrollend). - reportReader('input'); + if (reportReader('input', direction)) { + pinned = false; + publish(); + } + onScrollEnd(); return; } - gesture = { top: gesture?.top ?? target.scrollTop, direction }; pinned = false; publish(); - reportReader('input'); + reportReader('input', direction); }; const onWheel = (event: WheelEvent): void => { if (event.ctrlKey || event.metaKey || event.deltaY === 0) return; @@ -176,7 +231,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { : ['ArrowDown', 'PageDown', 'End', ' '].includes(event.key) ? 'down' : undefined; if (direction) begin(event, direction); }; - let pointer: number | undefined; const onPointerDown = (event: PointerEvent): void => { if (event.defaultPrevented || event.button !== 0 || event.pointerType === 'touch' || event.target !== target) return; @@ -188,6 +242,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { }; const onPointerUp = (): void => { pointer = undefined; + onScrollEnd(); const pending = gesture; if (!pending || pending.direction !== undefined) return; // Native track clicks can start their smooth scroll after pointerup. @@ -196,11 +251,13 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { requestAnimationFrame(() => { if (gesture !== pending || pending.direction !== undefined) return; gesture = undefined; + notifyIdle(); if (pinned) writeToTail(); }); }; let touchY: number | undefined; const onTouchStart = (event: TouchEvent): void => { + touchHeld = true; touchY = event.touches.length === 1 ? event.touches[0]!.clientY : undefined; }; const onTouchMove = (event: TouchEvent): void => { @@ -210,7 +267,12 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { } touchY = nextY; }; - const onTouchEnd = (): void => { touchY = undefined; }; + const onTouchEnd = (event: TouchEvent): void => { + touchY = undefined; + if (event.touches.length > 0) return; + touchHeld = false; + onScrollEnd(); + }; const onScroll = (): void => { awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; readingTurnId = readTurn(); @@ -238,6 +300,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }; const onScrollEnd = (): void => { + // An explicit navigation may already have retired the gesture while + // a pointer or touch was held. Release still has to wake publication. + notifyIdle(); const ended = gesture; if (!ended) return; const top = ended.top; @@ -246,11 +311,18 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // step report any continuation before retiring its input provenance. // This schedules no scroll and uses no time-based ignore window. requestAnimationFrame(() => requestAnimationFrame(() => { - if (gesture !== ended || ended.top !== top) return; - pinned = ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX; + if (gesture !== ended || ended.top !== top || pointer !== undefined || touchHeld) return; + // Input that can scroll and observed reader movement already release + // the pin. Settling an unmoved edge gesture must not release it too. + pinned = pinned || (ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX); gesture = undefined; + notifyIdle(); publish(); if (pinned) writeToTail(); + // An anchor navigation can supersede the last in-flight page while + // the gesture is held. Recheck its edge once after publication; + // waiting for another movement would strand a reader at scrollTop 0. + if (ended.direction) reportReader('settled'); })); }; target.addEventListener('wheel', onWheel, { passive: true }); @@ -311,18 +383,26 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { target.removeEventListener('scrollend', onScrollEnd); target.style.overflowAnchor = previousOverflowAnchor; gesture = undefined; + pointer = undefined; + touchHeld = false; if (root === target) root = null; }; }, pinToTail() { gesture = undefined; + pointer = undefined; + touchHeld = false; pinned = true; + queueMicrotask(notifyIdle); writeToTail(); publish(); }, releasePin() { gesture = undefined; pinned = false; + // Commands can originate in a React effect. Publish before their next + // positioning frame, outside React's lifecycle, if a range is pending. + queueMicrotask(notifyIdle); awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); }, diff --git a/packages/ui/src/transcript-viewport-navigation.ts b/packages/ui/src/transcript-viewport-navigation.ts index d25513cdaf..bc731ed286 100644 --- a/packages/ui/src/transcript-viewport-navigation.ts +++ b/packages/ui/src/transcript-viewport-navigation.ts @@ -17,10 +17,44 @@ * under the License. */ -/** Explicit viewport commands are consumed once, never replayed on mount or growth. */ +/** Bridge the active surface's scroll authority to conversation commands and publication. + * Publication outlives a viewport: only the source owner can invalidate its data. */ export function createTranscriptViewportNavigation() { const listeners = new Set<(sessionId: string) => void>(); + let viewport: { sessionId: string; commitIfIdle: (commit: () => void) => boolean } | undefined; + let pending: { sessionId: string; commit: () => void } | undefined; + const drain = (): void => { + const update = pending; + if (!update) return; + const commit = () => { + pending = undefined; + update.commit(); + }; + if (viewport?.sessionId === update.sessionId) viewport.commitIfIdle(commit); + else commit(); + }; return { + attachCommitScheduler(sessionId: string, authority: { + commitIfIdle(commit: () => void): boolean; + subscribeToIdle(listener: () => void): () => void; + }): () => void { + const attached = { sessionId, commitIfIdle: authority.commitIfIdle }; + viewport = attached; + const unsubscribe = authority.subscribeToIdle(drain); + queueMicrotask(drain); + return () => { + unsubscribe(); + if (viewport !== attached) return; + viewport = undefined; + // React cleanup may be running. Publish after it, without depending + // on a future source emission or a replacement viewport mounting. + queueMicrotask(drain); + }; + }, + commitRange(sessionId: string, commit: () => void): void { + pending = { sessionId, commit }; + queueMicrotask(drain); + }, followLatest(sessionId: string): void { for (const listener of [...listeners]) listener(sessionId); }, diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 5359479af1..103df374c8 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -22,14 +22,13 @@ * authority that owns it (`transcript-scroll-authority.ts`). * * A command is one-shot — jump to a turn the reader picked, ask for the history - * above them — and it releases the pin first, because the authority writes - * nothing while the pin is released and so a command can never be fighting a - * policy. That was the shape every previous round of this code had. + * above them — and it releases the pin first, so explicit navigation does not + * fight following. The authority also owns range publication and its one-shot + * reading-anchor restoration. * * What decides whether the reader wants either thing is never re-derived here. * "They have left the tail" is the pin, and the pin has one owner. Nothing here - * compensates for content that lands above them either; `overflow-anchor: auto` - * does that continuously, and for free. + * compensates for content that lands above them; that belongs to the authority. */ import { useEffect, useRef, useState, type RefObject } from 'react'; @@ -109,6 +108,10 @@ export function useChatScroll(input: { // which lands after passive effects, so this is still installed in time. useEffect(() => authority.attach(input.scrollRef.current), [authority, input.scrollRef]); + useEffect(() => input.sessionId + ? input.viewportNavigation?.attachCommitScheduler(input.sessionId, authority) + : undefined, [authority, input.sessionId, input.viewportNavigation]); + // A new conversation either resumes a semantic reading position or arrives // at its tail. Releasing before an async fill is essential: an empty // transcript clamps every pixel offset to zero, but it cannot erase a Turn @@ -181,18 +184,13 @@ export function useChatScroll(input: { : input.hasNewerHistory === true && canPrefetch; const requestHistory = (direction: 'up' | 'down'): void => { if (inFlight[direction]) return; - // The browser anchors the reader against everything that lands above - // them, with one exception: it declines while the scroller sits at zero. - if (direction === 'up' && !authority.getSnapshot().pinned && root.scrollTop < 1) { - root.scrollTop = 1; - } inFlight[direction] = true; void Promise.resolve(prefetchRef.current?.(direction === 'up' ? 'older' : 'newer')) .then( // Chaining pages needs a re-check here, because the render that the // landed rows caused ran while this direction still counted as in // flight. A prefetch that issued no read has nothing to chain from. - (issued) => { inFlight[direction] = false; if (issued) check(); }, + (issued) => { inFlight[direction] = false; if (issued && !authority.isInputActive()) check(); }, () => { inFlight[direction] = false; }, ); }; @@ -203,6 +201,9 @@ export function useChatScroll(input: { const below = root.scrollHeight - root.clientHeight - root.scrollTop; if (canLoad('up') && above < screen * 2) requestHistory('up'); if (canLoad('down') && below < screen * 2) requestHistory('down'); + // Source pages may have arrived without entering the DOM yet. Its old + // IDs cannot trim that source; settled rechecks after publication. + if (authority.isInputActive()) return; if (above <= screen * 6 && below <= screen * 6) return; const rect = root.getBoundingClientRect(); const turns = [...root.querySelectorAll('[data-turn-id]')]; @@ -228,7 +229,12 @@ export function useChatScroll(input: { bandCheck.current = check; // Both phases matter: a gesture at an edge moves nothing and so reports // only `input`, and that is exactly where the next page is wanted. - const stopWatchingReader = authority.subscribeToReaderScroll(() => check()); + const stopWatchingReader = authority.subscribeToReaderScroll((phase, direction) => { + check(); + // An existing fill also satisfies this request. Preserve the gesture + // while giving the authority the intent that pixels alone cannot tell. + return phase === 'input' && direction === 'up' && canLoad('up'); + }); // A resize redefines the band itself — the screen it counts in is the // root's own height — while the reader and the messages stand still. The // authority publishes only when its snapshot changes, so a resize that diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index 49b1e967d7..8d14574f3e 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -19,7 +19,7 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SessionSummary } from '@maka/core/session'; @@ -234,6 +234,10 @@ export const ExistingConversation: Story = { await expect(announcement).toBeEmptyDOMElement(); await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument(); + // Closing replaces the wheel with a new trigger and restores focus next frame. + await waitFor(() => expect(within(canvasElement).getByRole('button', { + name: /切换当前任务模型|Switch model for this task/, + })).toHaveFocus()); await userEvent.keyboard('{ArrowDown}'); await expect(announcement).toHaveTextContent(warning); }, diff --git a/patches/@astryxdesign+core+0.5.2.patch b/patches/@astryxdesign+core+0.5.2.patch index 51d956e14d..a08e3923df 100644 --- a/patches/@astryxdesign+core+0.5.2.patch +++ b/patches/@astryxdesign+core+0.5.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js -index 5046603..ec36a00 100644 +index 5046603..f34333e 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js @@ -22,7 +22,7 @@ @@ -41,7 +41,7 @@ index ff34874..0f5ae14 100644 export declare function ChatLayout({ children, composer, density, emptyState, scrollButton, scrollRef: externalScrollRef, xstyle, className, style, 'data-testid': testId, ref, ...rest }: ChatLayoutProps): import("react").JSX.Element; export declare namespace ChatLayout { diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js -index ff9b9fa..d20288e 100644 +index ff9b9fa..f681cd0 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js @@ -85,7 +85,8 @@ const styles = { @@ -218,6 +218,27 @@ index eac4c4b..a21c0c4 100644 return def; } function buildLanguageUncached(lang) { +diff --git a/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js b/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js +index 4765e55..a93b859 100644 +--- a/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js ++++ b/node_modules/@astryxdesign/core/dist/CodeBlock/CodeBlock.js +@@ -218,15 +218,8 @@ function renderLines(lines, highlightSet, renderLineContent, lineNumbers, chunkS + for (let start = 0; start < lines.length; start += chunkSize) { + const end = Math.min(start + chunkSize, lines.length); + const chunkLines = lines.slice(start, end); +- const estimatedHeight = `${chunkLines.length}lh`; + chunks.push(/*#__PURE__*/_jsx("div", { +- ...mergeProps({ +- className: "xb5mbof" +- }, { +- style: { +- containIntrinsicBlockSize: `auto ${estimatedHeight}` +- } +- }), ++ style: { contain: "layout style paint" }, + children: /*#__PURE__*/_jsx(CodeChunk, { + lines: chunkLines, + startIndex: start, diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts index 82ae62c..a97eff5 100644 --- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts diff --git a/patches/README.md b/patches/README.md index 49de7d4cf6..20ee6fadaf 100644 --- a/patches/README.md +++ b/patches/README.md @@ -148,6 +148,12 @@ keep reusing compiled regexes. A call-site language filter would duplicate the dependency's language list, discard the displayed label, and miss the shared CodeEditor path. Delete this hunk when upstream stops caching unsupported labels. +`CodeBlock` retains memoized line chunks, but lays them out without guessed +intrinsic heights. Replacing those estimates on first visibility changed the +transcript scroll range (#5184). Keep layout/style/paint containment. Remove +this hunk when upstream offers equivalent stable geometry; the default-mode +geometry CI covers 1200 lines without any ablation override. + `ChatComposerInput` synchronizes external controlled values into its editable DOM in a layout effect. A passive effect can leave the old multiline draft visible for a frame after the sent message is rendered; clearing it later diff --git a/scripts/perf/CI.md b/scripts/perf/CI.md index 67da8a7fc8..44da99444f 100644 --- a/scripts/perf/CI.md +++ b/scripts/perf/CI.md @@ -39,6 +39,35 @@ Protocol `warm-projection-codec-ms` retains production wire decoding but exclude - DOM-ready latency includes Playwright polling/IPC. Stream lag starts at renderer subscription delivery and ends at DOM mutation; neither is proof of pixels presented. These are laboratory measurements, not whole-page INP. Long tasks and CPU TaskDuration do not substitute for anchor geometry, energy watts or OS wakeups. - Repeated actions use a warm process. First-action samples are labelled separately, not called cold application startup. Storybook reloads for cold-scroll trials, then repeats expansion and closure in the same mounted story. Heap readings include garbage awaiting collection and do not establish a leak by themselves. Idle retains ten approximately one-second CPU/heap samples and the CPU profile; task duration is normalized by the actual CDP timestamp interval. Browser-context traces retain DOM snapshots, and their overhead is included in the laboratory timings. Long-task/LoAF rows labelled case-total include every operation after observer installation in that case. Actual disk-cold startup, RTT injection, physical presentation, wakeups and long-duration leak acceptance remain outside this first harness. +## #5184 production layout measurements + +The frontend lane measures the current production layout. +`geometry-navigation.spec.ts` reloads three renderer documents in one Desktop ++ Host process. It measures renderer mount, older +history and return to latest separately, including CDP task/layout time and +long tasks. The long-task observer has a deliberate busy-task control after +measurement. Mount is a warm-process document reload, not disk-cold startup; +history uses the existing prompt rail, not wheel-triggered fill/trim. + +`node scripts/perf/geometry-ablation.mjs` runs the three fixed-range stories +(mixed 24 turns, 45 tools, 1200-line code), with three repetitions each. +Its mount CPU/layout counters are recorded before the +first upward sweep, so deferred work cannot disappear from the comparison. +Scroll metrics cover only that cold upward sweep. Older reports included two +return sweeps and are not equivalent timing workloads. Unasserted per-step +anchors, LoAF and heap diagnostics are no longer collected by this driver. +The script emits the shared report format and full per-frame geometry JSON. +Both probes run sequentially in the existing frontend job and upload through +its existing artifact step. No additional workflow or production switch exists. + +Reports are `frontend-geometry-navigation.{json,md}` and +`frontend-geometry-ablation.{json,md}`, plus `geometry-ablation.json`. +Compare production reports from the baseline and candidate commits, including every raw sample; +three samples do not establish a robust p95. CI success means the scenarios +and measurements worked, not that a timing budget or product geometry contract +passed. The separate `--assert-stable` flag remains an explicit geometry +assertion, not a hidden performance threshold. + ## Validating a new workflow before merge GitHub cannot dispatch a new workflow absent from the default branch. For this PR only, temporarily add `push: { branches: [] }` to these workflows, push and inspect both jobs/artifacts. Remove that trigger after verification. Do not merge to obtain a run, add `pull_request_target`, or leave a permanent automatic trigger. Record both the measured SHA and final SHA in the PR when the sole subsequent change removes the validation trigger. diff --git a/scripts/perf/geometry-ablation.mjs b/scripts/perf/geometry-ablation.mjs new file mode 100644 index 0000000000..e6f0b0ccf4 --- /dev/null +++ b/scripts/perf/geometry-ablation.mjs @@ -0,0 +1,283 @@ +/* + * 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. + */ + +// Fixed-range geometry gate and performance samples, fresh DOM per trial. +// Uses production ComposedShell stories; no Host, paging or streaming here. +import { fileURLToPath } from 'node:url'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +if (process.versions.electron) { + const { app, BrowserWindow } = await import('electron'); + // Electron waits for ESM evaluation before ready: awaiting ready at module + // scope would deadlock startup. + void app.whenReady().then(async () => { + const window = new BrowserWindow({ + width: 1200, + height: 900, + show: true, + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + }, + }); + await window.loadURL('about:blank'); + }); +} else { + const { _electron, expect } = await import('@playwright/test'); + const { startStaticServer } = await import('../storybook-visual-smoke.mjs'); + const { report, summarize } = await import('./report.mjs'); + const server = await startStaticServer('apps/desktop/storybook-static'); + let app; + const output = path.resolve(process.env.GEOMETRY_OUTPUT ?? 'perf-results/geometry-ablation.json'); + const repetitions = Number(process.env.GEOMETRY_REPETITIONS ?? 3); + const scenes = [ + ['geometry-mixed-24-turns', 24], + ['performance-45-tools', 1], + ['geometry-long-code', 1], + ].filter(([id]) => !process.env.GEOMETRY_SCENE || id === process.env.GEOMETRY_SCENE); + if (!scenes.length || !Number.isInteger(repetitions) || repetitions < 1) { + await server.close(); + throw new Error('Invalid geometry scene or repetition count'); + } + const rows = []; + try { + app = await _electron.launch({ args: [fileURLToPath(import.meta.url)], timeout: 30_000 }); + const page = await app.firstWindow(); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'reduce' }); + const cdp = await page.context().newCDPSession(page); + const browser = await cdp.send('Browser.getVersion'); + await cdp.send('Performance.enable'); + await page.addInitScript(() => { + const style = document.createElement('style'); + style.textContent = `*, *::before, *::after { transition:none !important; animation:none !important; } + [data-chat-scroll-container] { scroll-behavior:auto !important; }`; + const attach = () => { + if (document.documentElement) document.documentElement.append(style); + }; + if (document.documentElement) attach(); + else + new MutationObserver((_, observer) => { + if (document.documentElement) { + attach(); + observer.disconnect(); + } + }).observe(document, { childList: true }); + const probe = (window.__geometry = { + frames: [], + tasks: [], + phase: 'mount', + firstRootMs: null, + }); + new PerformanceObserver((list) => + probe.tasks.push( + ...list + .getEntries() + .map((e) => ({ start: e.startTime, duration: e.duration, phase: probe.phase })), + ), + ).observe({ type: 'longtask', buffered: true }); + const frame = () => { + const root = document.querySelector('[data-chat-scroll-container]'); + if (root) { + probe.firstRootMs ??= performance.now(); + probe.frames.push({ + ms: performance.now(), + h: root.scrollHeight, + t: root.scrollTop, + v: root.clientHeight, + phase: probe.phase, + }); + } + if (probe.phase !== 'done') requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + }); + const paint = () => + page.evaluate( + () => + new Promise((resolve) => { + let frames = 4; + const step = () => (--frames ? requestAnimationFrame(step) : resolve()); + requestAnimationFrame(step); + }), + ); + const metrics = () => + page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]'); + return { + h: root.scrollHeight, + t: root.scrollTop, + v: root.clientHeight, + count: root.querySelectorAll('.maka-transcript-turn').length, + }; + }); + for (const [scene, turns] of scenes) { + for (let trial = 0; trial < repetitions; trial++) { + await page.goto( + `${server.baseUrl}/iframe.html?id=product-shell-official-appshell--${scene}&viewMode=story`, + ); + await expect(page.locator('.maka-transcript-turn')).toHaveCount(turns); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('.maka-markdown-pending')).toHaveCount(0); + await expect + .poll(async () => { + const m = await metrics(); + return m.h - m.v - m.t; + }) + .toBeLessThanOrEqual(4); + const initial = await metrics(); + expect(initial.h).toBeGreaterThan(initial.v * 3); + const start = await page.evaluate(() => { + window.__geometry.phase = 'cold-up'; + return performance.now(); + }); + const beforeCpu = await cdp.send('Performance.getMetrics'); + const box = await page.locator('[data-chat-scroll-container]').boundingBox(); + const sweep = async (phase, deltaY) => { + await page.evaluate((phase) => { + window.__geometry.phase = phase; + }, phase); + for (let tick = 0; tick < 350; tick++) { + const before = await metrics(); + if (deltaY < 0 ? before.t <= 1 : before.h - before.v - before.t <= 1) return; + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: box.x + box.width / 2, + y: box.y + box.height / 2, + deltaX: 0, + deltaY, + }); + await paint(); + const after = await metrics(); + expect(after.count, 'fixed fixture membership changed').toBe(turns); + } + throw new Error(`${scene}/${phase} did not reach the edge within 350 wheel ticks`); + }; + await sweep('cold-up', -600); + const afterCpu = await cdp.send('Performance.getMetrics'); + await page.evaluate(() => { + window.__geometry.phase = 'done'; + }); + const state = await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container]'); + return { + ...window.__geometry, + liveNodes: root.querySelectorAll('*').length, + remainingAuto: [...root.querySelectorAll('*')].filter( + (el) => getComputedStyle(el).contentVisibility === 'auto', + ).length, + }; + }); + expect(state.remainingAuto, 'missed a lazy boundary').toBe(0); + const up = state.frames.filter((f) => f.phase === 'cold-up'); + const heights = [initial.h, ...up.map((f) => f.h)]; + const maxReverse = Math.max(0, ...up.slice(1).map((f, i) => f.t - up[i].t)); + const metric = (list, name) => list.metrics.find((m) => m.name === name)?.value ?? 0; + const row = { + scene, + trial, + initial, + readyMs: start, + // CDP duration counters reset on document navigation. + mountLayoutMs: metric(beforeCpu, 'LayoutDuration') * 1000, + mountTaskMs: metric(beforeCpu, 'TaskDuration') * 1000, + firstRootMs: state.firstRootMs, + heightDrift: Math.max(...heights) - Math.min(...heights), + maxReverse, + maxTaskMs: Math.max(0, ...state.tasks.map((t) => t.duration)), + scrollMaxTaskMs: Math.max( + 0, + ...state.tasks.filter((t) => t.start >= start).map((t) => t.duration), + ), + layoutMs: + (metric(afterCpu, 'LayoutDuration') - metric(beforeCpu, 'LayoutDuration')) * 1000, + ...state, + }; + rows.push(row); + await mkdir(path.dirname(output), { recursive: true }); + await writeFile( + output, + JSON.stringify( + { + browser, + viewport: '1200x900', + repetitions, + conditions: + 'Same Electron; fresh DOM per trial; fonts and Markdown ready; no offscreen box reads; real CDP upward wheel. Synthetic fixed-range production stories, no Host or paging.', + rows, + }, + null, + 2, + ), + ); + console.log( + JSON.stringify({ + scene, + trial, + heightDrift: row.heightDrift, + maxReverse, + readyMs: Math.round(start), + maxTaskMs: row.maxTaskMs, + scrollMaxTaskMs: row.scrollMaxTaskMs, + layoutMs: Math.round(row.layoutMs), + }), + ); + if (process.argv.includes('--assert-stable')) { + expect(row.heightDrift, `${scene}: fixed-range height drift`).toBeLessThanOrEqual(1); + expect(maxReverse, `${scene}: upward scroll reversed`).toBeLessThanOrEqual(1); + } + } + } + await report( + 'frontend-geometry-ablation', + { + browser, + repetitions, + viewport: '1200x900', + conditions: + 'One Electron process, production layout, fresh DOM per trial. Mount metrics include document navigation and readiness polling; not disk-cold startup or screen presentation.', + limits: + 'Synthetic fixed-range production components, no Host or paging. Three samples per scene by default; p95 is the maximum. --assert-stable gates cold upward height and monotonicity; timing measurements have no threshold. Scroll layout/task samples cover only the cold upward sweep.', + }, + scenes.flatMap(([scene]) => { + const group = rows.filter((r) => r.scene === scene); + return [ + 'readyMs', + 'mountLayoutMs', + 'mountTaskMs', + 'maxTaskMs', + 'scrollMaxTaskMs', + 'layoutMs', + 'heightDrift', + 'maxReverse', + ].map((metric) => ({ + scenario: scene, + metric, + ...summarize(group.map((r) => r[metric])), + })); + }), + ); + console.log(`Report: ${output}`); + } finally { + await app?.close(); + await server.close(); + } +} diff --git a/scripts/perf/geometry-navigation.spec.ts b/scripts/perf/geometry-navigation.spec.ts new file mode 100644 index 0000000000..83b606ff9e --- /dev/null +++ b/scripts/perf/geometry-navigation.spec.ts @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect } from '@playwright/test'; +import { withE2eWindow } from '../../apps/desktop/e2e/fixtures'; +import { outputDir, report, summarize } from './report.mjs'; +import path from 'node:path'; + +test('production layout: document mount and older history', async () => { + test.setTimeout(180_000); + const samples: Array<{ + trial: number; + action: string; + ms: number; + taskMs: number; + layoutMs: number; + maxLongTaskMs: number; + longTaskCount: number; + }> = []; + await withE2eWindow( + { + seed: false, + readinessSelector: '[data-turn-id]', + e2eFixtureScenario: 'chat-prompt-rail', + locale: 'zh-CN', + showWindow: true, + tracePath: path.join(outputDir, 'geometry-navigation.trace.zip'), + }, + async (page) => { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'reduce' }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + const browser = await cdp.send('Browser.getVersion'); + await page.addInitScript(() => { + const tasks: Array<{ start: number; duration: number }> = []; + (window as any).__mountTasks = tasks; + new PerformanceObserver((list) => + tasks.push( + ...list.getEntries().map((e) => ({ start: e.startTime, duration: e.duration })), + ), + ).observe({ type: 'longtask', buffered: true }); + }); + const ready = async (turn: number) => { + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${turn}"]`)).toHaveCount(1); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('.maka-markdown-pending')).toHaveCount(0); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + }; + const metric = (m: { metrics: Array<{ name: string; value: number }> }, name: string) => + m.metrics.find((v) => v.name === name)!.value; + for (let trial = 0; trial < 3; trial++) { + const measure = async (action: string, run: () => Promise) => { + const before = await cdp.send('Performance.getMetrics'); + const from = action === 'mount' ? 0 : await page.evaluate(() => performance.now()); + const start = performance.now(); + await run(); + const ms = performance.now() - start; + const after = await cdp.send('Performance.getMetrics'); + const tasks = await page.evaluate( + (from) => + (window as any).__mountTasks + .filter((t: { start: number }) => t.start >= from) + .map((t: { duration: number }) => t.duration) as number[], + from, + ); + const sample = { + trial, + action, + ms, + // Navigation resets CDP counters; only same-document actions use deltas. + taskMs: + (metric(after, 'TaskDuration') - + (action === 'mount' ? 0 : metric(before, 'TaskDuration'))) * + 1000, + layoutMs: + (metric(after, 'LayoutDuration') - + (action === 'mount' ? 0 : metric(before, 'LayoutDuration'))) * + 1000, + maxLongTaskMs: Math.max(0, ...tasks), + longTaskCount: tasks.length, + }; + expect(sample.taskMs).toBeGreaterThanOrEqual(0); + expect(sample.layoutMs).toBeGreaterThanOrEqual(0); + samples.push(sample); + console.log(JSON.stringify(sample)); + }; + await measure('mount', async () => { + await page.reload(); + await ready(120); + }); + { + expect( + await page + .locator('[data-chat-scroll-container]') + .evaluate( + (root) => + [...root.querySelectorAll('*')].filter( + (el) => getComputedStyle(el).contentVisibility === 'auto', + ).length, + ), + ).toBe(0); + } + await measure('older', async () => { + await page + .locator('.maka-prompt-rail-tick[data-prompt-turn-id="turn-prompt-rail-1"]') + .click(); + await ready(1); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(0); + await expect( + page.getByRole('button', { + name: /^(滚动主对话到底部|Scroll main conversation to bottom)$/, + }), + ).toBeVisible(); + }); + await measure('latest', async () => { + await page + .getByRole('button', { + name: /^(滚动主对话到底部|Scroll main conversation to bottom)$/, + }) + .click(); + await ready(120); + }); + } + // Prove the observer works without contaminating any measured operation. + const control = await page.evaluate(async () => { + const from = performance.now(); + await new Promise((resolve) => + setTimeout(() => { + const start = performance.now(); + while (performance.now() - start < 100) { + /* observer control */ + } + setTimeout(resolve, 100); + }, 0), + ); + return (window as any).__mountTasks + .filter((t: { start: number }) => t.start >= from) + .map((t: { duration: number }) => t.duration) as number[]; + }); + expect(control.some((duration) => duration >= 90)).toBe(true); + await report( + 'frontend-geometry-navigation', + { + browser, + control, + samples, + viewport: '1400x900', + conditions: + 'One real Desktop + Host, three fresh renderer documents using production layout. Existing 120-turn fixture; three measurements per action.', + limits: + 'Mount is renderer reload in a warm application, not disk-cold process startup. DOM readiness includes driver polling, fonts and two rendering frames, not screen presentation. Older/latest uses prompt-rail navigation, not wheel-triggered paging. Trace overhead included; no performance threshold imposed.', + }, + ['mount', 'older', 'latest'].flatMap((action) => { + const group = samples.filter((s) => s.action === action); + return (['ms', 'taskMs', 'layoutMs', 'maxLongTaskMs', 'longTaskCount'] as const).map( + (metric) => ({ + scenario: action, + metric, + ...summarize(group.map((s) => s[metric])), + }), + ); + }), + ); + }, + ); +}); diff --git a/scripts/perf/geometry-results.md b/scripts/perf/geometry-results.md new file mode 100644 index 0000000000..d40389aef3 --- /dev/null +++ b/scripts/perf/geometry-results.md @@ -0,0 +1,55 @@ + + +# Transcript geometry contract (#5184) + +Resident Turns, timeline blocks and code chunks use real layout. The existing +bounded window remains; there is no size index or warm-up traversal. +Publication holds messages and range metadata together during reader input. +The session owns pending publication across viewport detach; the scroll +authority admits it and restores the reading anchor at commit. + +## Verification + +- Ordinary CI runs `GEOMETRY_REPETITIONS=1 node scripts/perf/geometry-ablation.mjs --assert-stable`. + Three fixed-range production stories cover mixed Turns, tools and long code. + Cold upward scrolling must keep height drift and reverse motion within 1px. +- `apps/desktop/e2e/scroll-geometry.spec.ts` exercises the native scrollbar: + held height/membership, monotonic movement, release-frame anchor and progress. +- `apps/desktop/e2e/transcript-scroll-cost.spec.ts` covers bounded paging and + reading anchors across range changes with consecutive native wheel ticks. +- Performance commands and comparison limits are in [CI.md](CI.md). + Timing success alone does not establish statistical non-regression. + +The fixed-range probe waits for fonts and Markdown readiness. It does not +establish stability during cold Markdown admission, streaming or media resize. +Scroll timing now covers only the first upward sweep; older reports also +included a downward/upward return and must not be compared as equal workloads. + +## Historical evidence + +The original experimental modes and results are preserved in Git, rather than +maintained as a second description of current behavior: + +- [Pre-change measurement source](https://github.com/apache/maka/tree/817a5737d) +- [Full experimental record before this cleanup](https://github.com/apache/maka/blob/f9cd77cbee8d697a999d61ad716708169275bbe9/scripts/perf/geometry-results.md) + +That experiment separated lazy layout drift from window-publication drift. +Its machine-specific measurements are historical evidence, not current-head +performance results. diff --git a/scripts/perf/playwright.config.ts b/scripts/perf/playwright.config.ts index 3605e1076f..b489e7b40b 100644 --- a/scripts/perf/playwright.config.ts +++ b/scripts/perf/playwright.config.ts @@ -20,7 +20,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: '.', - testMatch: 'frontend.spec.ts', + testMatch: ['frontend.spec.ts', 'geometry-navigation.spec.ts'], workers: 1, retries: 0, timeout: 180000,