diff --git a/.github/workflows/performance-frontend.yml b/.github/workflows/performance-frontend.yml index 1248ee945b..410367ba37 100644 --- a/.github/workflows/performance-frontend.yml +++ b/.github/workflows/performance-frontend.yml @@ -23,14 +23,14 @@ permissions: jobs: measure: runs-on: ubuntu-24.04 - timeout-minutes: 35 + timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '24.18.1' + node-version: "24.18.1" cache: npm - run: npm ci - run: npm --workspace @maka/desktop run build:workspace-deps @@ -38,7 +38,12 @@ jobs: - run: npm --workspace @maka/desktop run build-storybook - id: browsers run: npx playwright install --with-deps chromium + - name: Native history traversal never exposes an empty transcript + env: + MAKA_PERF_OUTPUT: ${{ github.workspace }}/perf-results/native-history + run: node scripts/perf/scroll-window.mjs - name: Electron and Host measurements + if: ${{ !cancelled() && steps.browsers.outcome == 'success' }} working-directory: apps/desktop env: MAKA_PERF_OUTPUT: ${{ github.workspace }}/perf-results diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index bb6b7f6561..03f9b87150 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -75,7 +75,7 @@ }, "scroll-geometry.spec.ts": { "tests": 1, - "electron": "a real Host history batch arrives during a held native scrollbar drag; release must publish that range while preserving the reading Turn and allow return to latest" + "electron": "a real Host history batch crosses preload during a native scrollbar drag; cold content must remain readable while held and after release, then return to latest reads the Host tail" }, "workhub-layout.spec.ts": { "tests": 2, diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index ae2964f05d..64682d8af6 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -18,7 +18,7 @@ */ import { _electron as electron, test as base, expect } from '@playwright/test'; -import type { ElectronApplication, Page } from '@playwright/test'; +import type { ElectronApplication, Locator, Page } from '@playwright/test'; import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -80,7 +80,7 @@ export async function ensureSidebarExpanded(page: Page): Promise { * The control reflects local admission readiness, not Host connectivity. * Merely mounting the editor does not mean target selection has finished. */ -export async function awaitSendReady(page: Page): Promise { +export async function awaitSendReady(page: Page | Locator): Promise { await expect(page.locator('.maka-composer button[type="submit"]')).toBeEnabled({ timeout: 20_000, }); diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index e530a28142..0c1d7a4d94 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -17,12 +17,12 @@ * under the License. */ -// Real native scrollbar input: stable held geometry, preserved reading anchor, -// and history progress after release. Fixed-range cold scrolling runs in CI too. +// Host history delivered through preload must preserve the reader throughout +// native input, including the first layout of previously unseen rows. import { test, expect } from '@playwright/test'; import { withE2eWindow } from './fixtures'; -test('native thumb keeps its geometry and releases history without moving the reader', async () => { +test('native thumb preserves a cold reader while admitting Host history', async () => { test.setTimeout(180_000); await withE2eWindow( { @@ -74,7 +74,9 @@ test('native thumb keeps its geometry and releases history without moving the re done: false, pointerDown: 0, pointerUp: 0, + originVisits: 0, readingId: undefined as string | undefined, + sample: undefined as number | undefined, frames: [] as Array<{ h: number; t: number; @@ -83,9 +85,13 @@ test('native thumb keeps its geometry and releases history without moving the re held: boolean; ms: number; anchorTop?: number; + sample?: number; }>, }; (window as any).__windowGeometry = state; + root.addEventListener('scroll', () => { + if (state.held && root.scrollTop === 0) state.originVisits++; + }, { capture: true }); root.addEventListener('pointerdown', () => { state.pointerDown++; state.held = true; @@ -103,8 +109,9 @@ test('native thumb keeps its geometry and releases history without moving the re range: turns.map((t) => t.dataset.transcriptTurnId).join(','), held: state.held, ms: performance.now(), + sample: state.sample, anchorTop: state.readingId - ? root.querySelector(`[data-turn-id="${state.readingId}"]`)?.getBoundingClientRect() + ? root.querySelector(`.maka-turn[data-turn-id="${state.readingId}"]`)?.getBoundingClientRect() .top : undefined, }); @@ -132,8 +139,32 @@ test('native thumb keeps its geometry and releases history without moving the re buttons: 1, clickCount: 1, }); - for (let step = 1; step <= 40; step++) { - const y = startY + ((start.top + 8 - startY) * step) / 40; + const visible = (sample?: number, anchorId?: string) => page.evaluate(({ sample, anchorId }) => { + const root = document.querySelector('[data-chat-scroll-container]')!; + const view = root.getBoundingClientRect(); + const turn = anchorId + ? root.querySelector(`.maka-turn[data-turn-id="${anchorId}"]`) + : [...root.querySelectorAll('.maka-turn[data-turn-id]')].find((el) => { + const box = el.getBoundingClientRect(); + return box.height > 0 && box.width > 0 && box.bottom > view.top && box.top < view.bottom; + }); + if (sample !== undefined) { + (window as any).__windowGeometry.sample = sample; + (window as any).__windowGeometry.readingId = turn?.dataset.turnId; + } + return { + id: turn?.dataset.turnId, top: turn?.getBoundingClientRect().top, + scrollHeight: root.scrollHeight, scrollTop: root.scrollTop, + }; + }, { sample, anchorId }); + const stationary: Array<{ before: Awaited>; after: Awaited> }> = []; + const positions = [10, 6, 10, 4]; + for (let step = 1; step <= positions.length; step++) { + await page.evaluate(() => { (window as any).__windowGeometry.sample = undefined; }); + const progress = positions[step - 1]; + // Cross the real origin, where native anchoring is unavailable, + // before reversing the same held thumb. + const y = startY + ((start.top + 1 - startY) * progress) / 10; await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: start.x, @@ -141,22 +172,45 @@ test('native thumb keeps its geometry and releases history without moving the re button: 'left', buttons: 1, }); - await page.waitForTimeout(25); + await page.waitForTimeout(50); + const before = await visible(step); + await page.waitForTimeout(300); + const after = await visible(undefined, before.id); + stationary.push({ before, after }); } await page.waitForTimeout(400); + await test.info().attach('held-reader-samples', { + body: JSON.stringify(stationary), contentType: 'application/json', + }); + for (const [index, { before, after }] of stationary.entries()) { + expect(before.id, 'the held viewport must contain a rendered Turn').toBeTruthy(); + expect(after.id, 'a stationary pointer must not replace the reader').toBe(before.id); + expect(Math.abs(after.top! - before.top!), 'the held reader must stay in place').toBeLessThanOrEqual(1); + if (index > 0) { + const previous = Number(stationary[index - 1].after.id!.split('-').at(-1)); + const current = Number(before.id!.split('-').at(-1)); + if (positions[index] > positions[index - 1]) expect(current, 'upward input must not move toward newer Turns').toBeLessThanOrEqual(previous); + else expect(current, 'reversing input must move toward newer Turns').toBeGreaterThanOrEqual(previous); + } + } const reading = await page.evaluate(() => { + (window as any).__windowGeometry.sample = undefined; const root = document.querySelector('[data-chat-scroll-container]')!; - const top = root.getBoundingClientRect().top; - const turn = [...root.querySelectorAll('[data-turn-id]')].find( - (el) => el.getBoundingClientRect().bottom > top, - )!; + const viewport = root.getBoundingClientRect(); + const turn = [...root.querySelectorAll('.maka-turn[data-turn-id]')].find( + (el) => { + const box = el.getBoundingClientRect(); + return box.height > 0 && box.width > 0 && box.bottom > viewport.top && box.top < viewport.bottom; + }, + ); + if (!turn) throw new Error('Native drag left no rendered reading Turn'); (window as any).__windowGeometry.readingId = turn.dataset.turnId; return { id: turn.dataset.turnId!, top: turn.getBoundingClientRect().top }; }); await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: start.x, - y: start.top + 8, + y: startY + (start.top + 1 - startY) * 0.4, button: 'left', buttons: 0, clickCount: 1, @@ -183,37 +237,32 @@ test('native thumb keeps its geometry and releases history without moving the re state.done = true; return state; }); + expect(result.originVisits, 'the held thumb must exercise the scroll origin').toBeGreaterThan(0); const held = result.frames.filter((f: any) => f.held); await test.info().attach('scroll-geometry-frames', { body: JSON.stringify(result), contentType: 'application/json', }); - const heightDrift = - Math.max(...held.map((f: any) => f.h)) - Math.min(...held.map((f: any) => f.h)); const ranges = new Set(held.map((f: any) => f.range)); + for (const [index, { before }] of stationary.entries()) { + const frames = held.filter((f: any) => f.sample === index + 1); + expect(frames.length, 'every stationary hold must be observed across frames').toBeGreaterThan(1); + for (const frame of frames) { + expect(frame.anchorTop, 'the held reader must remain mounted').toBeDefined(); + expect(Math.abs(frame.anchorTop - before.top!), 'every stationary frame retains the reading line').toBeLessThanOrEqual(1); + } + } expect(result.pointerDown).toBe(1); expect(result.pointerUp).toBe(1); - expect(heightDrift, 'height must remain constant while held').toBeLessThanOrEqual(1); - expect(ranges.size, 'resident membership must remain constant while held').toBe(1); - expect( - Math.max(0, ...held.slice(1).map((f: any, i: number) => f.t - held[i].t)), - 'upward native drag must not reverse', - ).toBeLessThanOrEqual(1); + // Prepending Host pages changes both height and scrollTop. Neither is + // a reader-displacement metric; measure the rendered Turn on release. + expect(ranges.size, 'the hold must exercise actual Host history publication').toBeGreaterThan(1); const released = result.frames.filter((f: any) => !f.held && f.anchorTop !== undefined); expect( Math.max(...released.map((f: any) => Math.abs(f.anchorTop - reading.top))), 'reading anchor must survive every release frame', ).toBeLessThanOrEqual(1); - await expect - .poll(() => - page - .locator('.maka-transcript-turn') - .evaluateAll((els) => - els.map((el) => (el as HTMLElement).dataset.transcriptTurnId).join(','), - ), - ) - .not.toBe(held[0].range); - const anchor = page.locator('[data-turn-id="' + reading.id + '"]'); + const anchor = page.locator('.maka-turn[data-turn-id="' + reading.id + '"]'); await expect(anchor).toHaveCount(1); await expect .poll(async () => Math.abs((await anchor.boundingBox())!.y - reading.top)) diff --git a/apps/desktop/e2e/side-chat-followups.spec.ts b/apps/desktop/e2e/side-chat-followups.spec.ts index 6f7c535b84..e87dbcc598 100644 --- a/apps/desktop/e2e/side-chat-followups.spec.ts +++ b/apps/desktop/e2e/side-chat-followups.spec.ts @@ -96,6 +96,7 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', const companion = page.locator('.maka-quote-companion'); const sideComposer = companion.locator(COMPOSER_INPUT); await sideComposer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(companion); await sideComposer.press('Enter'); await expect(companion).toContainText('Fake backend waiting'); const forkId = await page.evaluate(async (existingIds) => { @@ -106,6 +107,8 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', const queued = companion.locator('.maka-composer-queue'); for (const text of ['first follow-up', 'second follow-up', 'retract this follow-up']) { await sideComposer.fill(text); + // The queue is optimistic; its appearance does not settle send admission. + await awaitSendReady(companion); await sideComposer.press('Enter'); await expect(queued).toContainText(text); } @@ -127,6 +130,7 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); await sideComposer.fill('steer the current response'); + await awaitSendReady(companion); await sideComposer.press('Shift+Enter'); await expect(companion).toContainText('Acknowledged steering: steer the current response'); await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ @@ -147,15 +151,18 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', // Hold a second Turn before its first token, queue two successors, then // release it by steering. All three replies must survive the Host handoffs. await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT); + await awaitSendReady(companion); await sideComposer.press('Enter'); await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); for (const text of ['successor one', 'successor two']) { await sideComposer.fill(text); + await awaitSendReady(companion); await sideComposer.press('Enter'); await expect(queued).toContainText(text); } await page.screenshot({ path: testInfo.outputPath('side-chat-queue.png'), fullPage: true }); await sideComposer.fill('release the held response'); + await awaitSendReady(companion); await sideComposer.press('Shift+Enter'); await expect(companion).toContainText('Acknowledged steering: release the held response'); await expect(companion).toContainText('Fake backend received: successor one', { timeout: 20_000 }); @@ -165,10 +172,12 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await page.screenshot({ path: testInfo.outputPath('side-chat-settled.png'), fullPage: true }); await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT); + await awaitSendReady(companion); await sideComposer.press('Enter'); await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); for (const text of ['reconnected successor one', 'reconnected successor two']) { await sideComposer.fill(text); + await awaitSendReady(companion); await sideComposer.press('Enter'); await expect(queued).toContainText(text); } diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 68e0fa92f3..496cde3d7a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -853,7 +853,7 @@ "react": 1 }, "importSpecifiers": 103, - "nonTriviaTokens": 13381 + "nonTriviaTokens": 13368 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 561497124c..c0ac030ecd 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -262,6 +262,7 @@ describe('app shell session UI state controller', () => { store: { sessionId: 'session', range: () => ({ sessionId: 'session' }), + pendingNavigation: () => undefined, sequenceForTurn: () => 17, newestDurableUserSequence: () => 17, snapshot: () => ({ messages: [] }), @@ -291,6 +292,7 @@ describe('app shell session UI state controller', () => { store: { sessionId: 'stale', range: () => ({ sessionId: 'stale' }), + pendingNavigation: () => undefined, sequenceForTurn: () => { sequenceReads += 1; return 17; @@ -322,6 +324,7 @@ describe('app shell session UI state controller', () => { store: { sessionId: 'session', range: () => ({ sessionId: 'session' }), + pendingNavigation: () => undefined, sequenceForTurn: () => null, newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), @@ -357,6 +360,7 @@ describe('app shell session UI state controller', () => { store: { sessionId: 'session', range: () => ({ sessionId: 'session' }), + pendingNavigation: () => undefined, sequenceForTurn: () => null, newestDurableUserSequence: () => 29, snapshot: () => ({ messages: [{ id: 'latest' }] }), diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 7a433b9364..e79a36a1d3 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -1329,7 +1329,11 @@ test('a fill is issued once per window and again as soon as the window moves', a sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, async acknowledgeTail() {}, - async loadBefore() { reads += 1; }, + async loadBefore(_anchor, maxBytes) { + assert.equal(maxBytes, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + 'a background projection keeps its existing scan budget'); + reads += 1; + }, async loadAfter() {}, async loadAround() {}, async loadLatest() {}, async close() {}, })); for (const batch of encodeDesktopTranscriptSnapshot({ @@ -1355,11 +1359,12 @@ test('reports each tail the window reaches once, and none while it is parked', a const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; const store = transcriptStore(); const acknowledged: number[] = []; + const readBudgets: number[] = []; // The visible reader path: only a controller that acknowledges reports a tail. const controller = createRecoveringDesktopTranscriptRangeController(store, async () => ({ ...identity, readThroughMessageId: null, async acknowledgeTail(through) { acknowledged.push(through); }, - async loadBefore() {}, async loadAfter() {}, async loadAround() {}, + async loadBefore(_anchor, maxBytes) { readBudgets.push(maxBytes); }, async loadAfter() {}, async loadAround() {}, async loadLatest() {}, async close() {}, }), { onError() {} }); const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -1371,6 +1376,10 @@ test('reports each tail the window reaches once, and none while it is parked', a })) store.accept(batch); await settle(); assert.deepEqual(acknowledged, [1]); + await controller.loadBefore(); + assert.equal(readBudgets.length, 1); + assert.ok(readBudgets[0]! > 0 && readBudgets[0]! < DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + 'the visible reader uses an interactive page budget rather than the IPC ceiling'); for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: 1, durableThrough: 2, diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index a009138a35..96e50a9a9b 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -236,6 +236,20 @@ describe('useSessionNavigationReads', () => { }); describe('createSessionOpenCommand', () => { + it('distinguishes successive jumps even when the clock does not advance', (t) => { + t.mock.method(Date, 'now', () => 1); + const targets: Array<{ nonce: number } | null> = []; + const deps = { + activateSession() {}, exitWorkHub() {}, selectSessionSurface() {}, + setSearchTarget: (target: { nonce: number } | null) => targets.push(target), + }; + const open = createSessionOpenCommand(deps); + open('a', 'turn-1', 1); + open('a', 'turn-1', 1); + createSessionOpenCommand(deps)('a', 'turn-1', 1); + assert.equal(new Set(targets.map((target) => target!.nonce)).size, 3); + }); + it('orders the jump and preserves turn-target clearing semantics', () => { const calls: string[] = []; const targets: unknown[] = []; diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index 65aa195944..e867490437 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -137,8 +137,8 @@ describe('session workspace action identity', () => { act(() => workspace.setActiveId(sessionA)); assert.deepEqual(workspace.retiredSessionIds([{ id: sessionB }]), [sessionA]); - // The real publication scheduler may hold a ready source while the reader - // is interacting. A retired queued source may not replace the displayed + // Publication is scheduled outside the current React lifecycle. + // A retired queued source may not replace the displayed // Session or publish its reader. act(() => workspace.setActiveId(sessionC)); for (const batch of encodeDesktopTranscriptSnapshot({ @@ -148,8 +148,8 @@ describe('session workspace action identity', () => { let blocked = true; let idle!: () => void; const detach = workspace.sessionUiController.transcriptViewportNavigation.attachCommitScheduler(sessionC, { - commitIfIdle: (commit) => { if (blocked) return false; commit(); return true; }, - subscribeToIdle: (listener) => { idle = listener; return () => {}; }, + subscribeToReaderScroll: () => () => {}, + commitRange: (commit) => { if (blocked) idle = commit; else commit(); }, }); let publications = 0; await act(async () => workspace.publishTranscript( diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts index 279e9610be..ae626e671d 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -89,6 +89,7 @@ test('a completed resident bookmark does not reload after streaming settlement e store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), + pendingNavigation: () => undefined, sequenceForTurn: (turnId: string) => fixture.replica.snapshot().durable .find(({ message }) => message.turnId === turnId)?.sequence ?? null, newestDurableUserSequence: () => 2, @@ -142,6 +143,7 @@ test('reopening a bookmark at the current Turn retains content persisted later i store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), + pendingNavigation: () => undefined, sequenceForTurn: (turnId) => fixture.replica.snapshot().durable .find(({ message }) => message.turnId === turnId)?.sequence ?? null, newestDurableUserSequence: () => 2, @@ -181,6 +183,7 @@ test('repeated message notifications share one pending restore and cancellation store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), + pendingNavigation: () => undefined, sequenceForTurn: () => null, newestDurableUserSequence: () => 2, snapshot: () => ({ messages: ['old restored range'] }), @@ -222,6 +225,7 @@ test('switching away and back creates a fresh restore while clearing search does store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), + pendingNavigation: () => undefined, sequenceForTurn: () => null, newestDurableUserSequence: () => 2, snapshot: () => ({ messages: [] as string[] }), @@ -266,6 +270,7 @@ test('effect teardown followed by setup lets only the replacement restore settle store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), + pendingNavigation: () => undefined, sequenceForTurn: () => null, newestDurableUserSequence: () => 2, snapshot: () => ({ messages: ['replacement range'] }), diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 6ddbb6a8df..4451617294 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -40,6 +40,55 @@ import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; afterEach(cleanupFakeDom); +test('a resident navigation supersedes a pending history replacement without rereading an idle window', async () => { + const sessionId = JSON.stringify(['host-1', 'session-1']); + const store = new DesktopTranscriptRangeStore(sessionId); + const pending = deferred(); + const reads: number[] = []; + const publish = (sequence: number, navigation?: number) => { + const turnId = sequence === 10 ? 'a' : 'b'; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + durableThrough: 20, durable: [{ sequence, message: { + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + } }], overlay: [], hasOlder: true, hasNewer: true, + }, navigation)) store.accept(batch); + }; + publish(20); + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + acknowledgeTail: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, + close: async () => {}, loadLatest: async () => assert.fail('unexpected tail navigation'), + async loadAround(sequence, _bytes, navigation) { + reads.push(sequence); + if (sequence === 10) await pending.promise; + publish(sequence, navigation); + }, + })); + const lifecycle = createTranscriptRestoreLifecycle(); + const navigate = (turnId: string, sequence: number, nonce: number) => restoreSessionTranscriptRange({ + lifecycle, sessionId, controller, searchTarget: { sessionId, turnId, sequence, nonce }, + isCurrent: () => true, setReadingAnchor() {}, onError: (error) => assert.fail(String(error)), + }); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + try { + navigate('b', 20, 1); + await settle(); + assert.deepEqual(reads, [], 'an idle resident target needs no read'); + navigate('a', 10, 2); + await settle(); + navigate('b', 20, 3); + await settle(); + pending.resolve(); + await settle(); + assert.deepEqual(store.snapshot().messages.map((message) => message.turnId), ['b'], + 'the late A response must not replace the newer B navigation'); + } finally { + pending.resolve(); + await controller.close(); + } +}); + test('sending before transcript open completes supersedes the queued bookmark without delaying admission', { timeout: 5_000 }, async () => { const sessionId = JSON.stringify(['host-1', 'session-1']); const store = new DesktopTranscriptRangeStore(sessionId); @@ -223,6 +272,7 @@ test('a read superseded by a Host epoch change leaves the bookmark alone', async store: { sessionId, range: () => ({ sessionId }), + pendingNavigation: () => undefined, sequenceForTurn: () => null, newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), @@ -268,6 +318,7 @@ function controllerFixture() { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), retain: (_oldest: number | null, _newest: number | null) => false, + pendingNavigation: () => undefined, sequenceForTurn: (_turnId: string, _edge?: 'first' | 'last'): number | null => null, newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index f805ffb9bc..32a04d154a 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -248,6 +248,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { sessionId: 'session-a', range: () => ({ sessionId: 'session-a' }), retain: () => false, + pendingNavigation: () => undefined, sequenceForTurn: (turnId: string) => { const sequence = messages.findIndex((message) => message.turnId === turnId); return sequence < 0 ? null : sequence; 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 b3fc364f8a..db85aec741 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -259,8 +259,8 @@ test('WorkHub holds transcript and live handoff together until publication is ad 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 () => {}; }, + subscribeToReaderScroll: () => () => {}, + commitRange(commit) { if (held) idle = commit; else commit(); }, }); const messages: StoredMessage[] = [ { type: 'user', id: 'user', turnId, text: 'held prompt', ts: 1 }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 42ac570674..d5673dd410 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -927,7 +927,6 @@ function AppShellContent({ setSearchModalOpen, searchScrollTarget, setSearchScrollTarget, - consumeSearchScrollTarget, closeSearchModal, searchModalDeps, searchModalOnNavigate, @@ -2285,7 +2284,7 @@ function AppShellContent({ currentSessionId={activeIdRef} rangeController={transcriptRangeRef} messages={messages} - searchTarget={searchScrollTarget?.handled ? null : searchScrollTarget} + searchTarget={searchScrollTarget} landmarkSessionId={ownerActiveId ?? null} clearSearchTarget={() => setSearchScrollTarget(null)} sessionUi={sessionUiController} @@ -2680,7 +2679,6 @@ function AppShellContent({ } : undefined } - onScrollTargetHandled={consumeSearchScrollTarget} restoreTargetTurn={Conversation.transcriptReadingPosition.restoreTarget( activeTranscriptReadingAnchor, activeUnavailableTranscriptRestore, diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 4d88727bf1..09ed6eddcb 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -28,6 +28,7 @@ interface TranscriptRangeStore { readonly hostEpoch?: string; }; sequenceForTurn(turnId: string, edge?: 'first' | 'last'): number | null; + pendingNavigation(): number | undefined; newestDurableUserSequence(): number | null; snapshot(): { readonly messages: readonly Message[] }; } @@ -282,9 +283,10 @@ export function restoreSessionTranscriptRange(options: { const residentSequence = currentTranscriptRange(controller, sessionId) ? controller.store.sequenceForTurn(target.turnId) : null; - // A resident target needs no page: the scroller reveals it from the window - // the Renderer already holds. - admitted = residentSequence !== null || target.sequence === undefined + // A resident target needs no page unless an older replacement is still + // pending. In that case this navigation must supersede the old read too. + admitted = (residentSequence !== null && controller.store.pendingNavigation() === undefined) + || target.sequence === undefined ? Promise.resolve() : controller.loadAround(target.sequence); } catch (error) { diff --git a/apps/desktop/src/renderer/features/search/use-shell-search.ts b/apps/desktop/src/renderer/features/search/use-shell-search.ts index 7a6f0ba227..b1a83d9498 100644 --- a/apps/desktop/src/renderer/features/search/use-shell-search.ts +++ b/apps/desktop/src/renderer/features/search/use-shell-search.ts @@ -37,17 +37,8 @@ export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef: turnId: string; sequence?: number; nonce: number; - handled?: boolean; } | null>(null); - const consumeSearchScrollTarget = useCallback((nonce: number) => { - setSearchScrollTarget((current) => - current?.nonce === nonce && !current.handled - ? { ...current, handled: true } - : current, - ); - }, []); - function closeSearchModal() { setSearchModalOpen(false); } @@ -63,7 +54,6 @@ export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef: setSearchModalOpen, searchScrollTarget, setSearchScrollTarget, - consumeSearchScrollTarget, closeSearchModal, searchModalDeps, searchModalOnNavigate, diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts index 9980caa686..82242b6aa4 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts @@ -40,13 +40,15 @@ export interface SessionOpenCommandDeps { * factory is what lets the order stay asserted after the controller's call site * moved below the shell (#4109). */ +let nextNavigation = 0; + export function createSessionOpenCommand(deps: SessionOpenCommandDeps) { return (sessionId: string, turnId?: string, sequence?: number): void => { deps.exitWorkHub(); deps.selectSessionSurface(); deps.activateSession(sessionId); deps.setSearchTarget( - turnId ? { sessionId, turnId, sequence, nonce: Date.now() } : null, + turnId ? { sessionId, turnId, sequence, nonce: ++nextNavigation } : null, ); }; } diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index 73880431dd..4ff6bb44de 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -31,6 +31,11 @@ import { TranscriptReadSupersededError } from '../../features/conversation/index import { projectDesktopStoredMessage } from '../../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; +// Interactive reads render real rows. Keep a page smaller than the IPC fragment +// ceiling so short replies cannot turn one read into dozens of mounted Turns. +// The Host still completes a boundary Turn that exceeds this page budget. +const TRANSCRIPT_READING_PAGE_BYTES = 16 * 1024; + /** * The Renderer's window onto one Session transcript. `loadAround` and * `loadLatest` replace the window and mint a navigation number; `loadBefore` @@ -331,9 +336,9 @@ export function createRecoveringDesktopTranscriptRangeController( void controller.ready().then(requireLive).catch(recovery.transcriptFailed); return { ...controller, - loadBefore: (maxBytes) => superseding(() => controller.loadBefore(maxBytes)), - loadAfter: (maxBytes) => superseding(() => controller.loadAfter(maxBytes)), - loadAround: (sequence, maxBytes) => superseding(() => controller.loadAround(sequence, maxBytes)), + loadBefore: (maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadBefore(maxBytes)), + loadAfter: (maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadAfter(maxBytes)), + loadAround: (sequence, maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadAround(sequence, maxBytes)), loadLatest: () => superseding(() => controller.loadLatest()), observationChanged: recovery.observationChanged, async close() { diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 75df23c912..65c20f39d4 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2048,7 +2048,7 @@ function injectNestedScroller(parent: Element): HTMLElement { } /** Answered turns, oldest first. `from` may go negative as history loads. */ -function transcriptTurns(from: number, count: number): StoredMessage[] { +function transcriptTurns(from: number, count: number, mixed = false): StoredMessage[] { return Array.from({ length: count }, (_, offset) => { const index = from + offset; const turnId = `turn-scroll-${index}`; @@ -2058,7 +2058,7 @@ function transcriptTurns(from: number, count: number): StoredMessage[] { `msg-scroll-${index}-a`, turnId, 499 - index * 2, - TAIL_LINES.slice(0, 4).join('\n\n'), + mixed ? mixedTurnText(Math.abs(index)) : TAIL_LINES.slice(0, 4).join('\n\n'), ), ]; }).flat(); @@ -2083,10 +2083,9 @@ function PartialHistoryHarness() { // it: the range moves to the Turn and a scroll target names it. onLoadTranscriptTurn: (loaded) => { setRange({ from: loaded.sequence, count: 4 }); - setTarget({ turnId: loaded.turnId, nonce: Date.now() }); + setTarget((previous) => ({ turnId: loaded.turnId, nonce: (previous?.nonce ?? 0) + 1 })); }, scrollTargetTurn: target, - onScrollTargetHandled: () => setTarget(undefined), hasOlderHistory: range.from > 1, hasNewerHistory: range.from + range.count <= PARTIAL_HISTORY_INDEX.length, // A fill extends the window; it never replaces what the reader jumped @@ -2387,7 +2386,7 @@ function SettledTranscriptHarness({ * settled before its turns were laid out would be asked for the next page * against the geometry of the previous one. */ -function HistoryHarness({ turns }: { turns: number }) { +function HistoryHarness({ turns, bounded = false, olderTurns = HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, mixed = false }: { turns: number; bounded?: boolean; olderTurns?: number; mixed?: boolean }) { const [range, setRange] = useState({ from: 0, count: turns }); const [viewportNavigation] = useState(createTranscriptViewportNavigation); useEffect(() => { @@ -2396,15 +2395,33 @@ function HistoryHarness({ turns }: { turns: number }) { return ( -HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, + hasOlderHistory: range.from > -olderTurns, + hasNewerHistory: bounded && range.from + range.count < turns, + onRetainWindow: bounded ? ({ firstTurnId, lastTurnId }) => { + // The real scroll hook chooses the retained band. This fixture only + // supplies the requested slice, standing in for the transcript store. + const from = Number(firstTurnId.replace('turn-scroll-', '')); + const last = Number(lastTurnId.replace('turn-scroll-', '')); + viewportNavigation.commitRange(activeSession!.id, () => setRange({ + from, count: last - from + 1, + })); + } : undefined, onPrefetchHistory: async (edge) => { + if (bounded && edge === 'newer') { + viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ + ...current, + count: Math.min(turns - current.from, current.count + HISTORY_BATCH), + }))); + await painted(2); + return true; + } if (edge !== 'older') return false; historyLoads.push(firstResidentTurnId() ?? '(none)'); viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ - from: current.from - HISTORY_BATCH, - count: current.count + HISTORY_BATCH, + from: Math.max(-olderTurns, current.from - HISTORY_BATCH), + count: current.count + Math.min(HISTORY_BATCH, current.from + olderTurns), }))); await painted(2); return true; @@ -2582,6 +2599,65 @@ export const TailPrefetchesHistoryUntilTheBandIsFull: Story = { }, }; +// Real path: a reader traverses a long session; useChatScroll requests older +// pages and trims distant Turns. Paging/storage is simulated at ChatView's +// callbacks; the production scroll policy, publication bridge and frame run. +export const HistoryWindowTraversal: Story = { + render: () => , +}; + +// Real path: the bounded Desktop transcript mounts and evicts mixed prose and +// code turns. The fixed-membership geometry scene below does not virtualize. +export const VirtualHistoryMixedContent: Story = { + render: () => , +}; + +// Real path: traverse a long Session, return through already read history, +// and jump to a loaded Turn whose body is currently outside the viewport. +export const VirtualHistoryContinuity: Story = { + render: () => , + play: async () => { + await historySettled(); + const root = tailScroller(); + const bodies = () => root.querySelectorAll('.maka-turn[data-turn-id]'); + await waitFor(() => { + expect(bodies().length).toBeGreaterThan(0); + expect(bodies().length).toBeLessThan(24); + expect(bodies().length).toBe(root.querySelectorAll('.maka-transcript-turn').length); + }); + + const traverse = async (direction: -1 | 1) => { + for (let step = 0; step < 160; step++) { + scrollAsReader(root, root.scrollTop + direction * root.clientHeight * 2); + await painted(3); + if (direction < 0 ? root.scrollTop <= 1 : root.scrollHeight - root.clientHeight - root.scrollTop <= 1) return; + } + throw new Error('History traversal did not reach its edge'); + }; + const tailId = bodies().item(bodies().length - 1).dataset.turnId; + await traverse(-1); + await traverse(1); + await painted(8); + expect(bodies().item(bodies().length - 1).dataset.turnId, 'returning through history must reach the original tail').toBe(tailId); + expect(root.scrollTop, 'the retained history stays inside the eviction band').toBeLessThanOrEqual(root.clientHeight * 6); + + const selected = bodies().item(bodies().length - 1); + const selection = document.getSelection()!; + const range = document.createRange(); + range.selectNodeContents(selected); + selection.removeAllRanges(); + selection.addRange(range); + const text = selection.toString(); + expect(text.length).toBeGreaterThan(0); + await traverse(-1); + await painted(8); + expect(selected.isConnected, 'an active selection must survive leaving the viewport').toBe(true); + expect(selection.toString()).toBe(text); + selection.removeAllRanges(); + await waitFor(() => expect(selected.isConnected, 'released offscreen content should unmount').toBe(false)); + }, +}; + // #4256: one Turn taller than several viewports, its reasoning / answer / tool // blocks each carrying a `data-maka-transcript-boundary` marker so sub-turn // content-visibility bounds them. Reasoning stays mounted while folded, so it is @@ -2638,22 +2714,25 @@ 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). +function mixedTurnText(i: number): string { + 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 prose + code; +} + +// Fixed membership isolates nested Markdown/code layout from history paging +// and virtual row mounting; VirtualHistoryMixedContent covers those together. 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)]; + assistant(`geometry-a-${i}`, turnId, 50 - i, mixedTurnText(i))]; }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, }; @@ -2680,6 +2759,13 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { process.querySelector('summary')!.click(); await waitFor(() => expect(process.open).toBe(true)); await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); + // Start cold scrolling only once the expanding clip exposes its full body. + await waitFor(() => { + const clip = process.querySelector('.maka-processing-clip')!.getBoundingClientRect(); + const content = process.querySelector('.maka-processing-content')!.getBoundingClientRect(); + expect(content.height).toBeGreaterThan(0); + expect(Math.abs(clip.height - content.height)).toBeLessThanOrEqual(1); + }); scrollAsReader(root, root.scrollHeight); await painted(4); } @@ -3150,10 +3236,9 @@ function PromptRailNavigationHarness() { transcriptTurnIndex: promptRailIndex, onLoadTranscriptTurn: (loaded) => { setFirstIndex(loaded.sequence); - setTarget({ turnId: loaded.turnId, nonce: Date.now() }); + setTarget((previous) => ({ turnId: loaded.turnId, nonce: (previous?.nonce ?? 0) + 1 })); }, scrollTargetTurn: target, - onScrollTargetHandled: () => setTarget(undefined), }} /> ); diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index eed5ef63c5..26c645cf37 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -276,6 +276,7 @@ export const ColoredWorkHistory: Story = { render: () => , play: async ({ canvasElement }) => { await waitFor(() => expect(canvasElement.querySelectorAll('[data-turn-accent="true"]')).toHaveLength(3)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-user-message .workhub-message-rail')).toHaveLength(3)); const turns = canvasElement.querySelectorAll('[data-turn-accent="true"]'); const stripeColor = (turn: HTMLElement) => getComputedStyle(turn.querySelector('.maka-user-message .workhub-message-rail')!, '::before').backgroundColor; expect(stripeColor(turns[0]!)).toBe(stripeColor(turns[2]!)); @@ -403,39 +404,39 @@ export const FilterWorkConversations: Story = { render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); writes.open.mockClear(); await userEvent.click(canvasElement.querySelector('.maka-user-message .workhub-message-rail') as HTMLElement); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(2)); expect(canvas.queryByText('请检查发布检查清单。')).toBeNull(); expect(canvas.queryByText('先讨论一下整体计划。')).toBeNull(); expect(canvas.getByText('继续补充异常场景。')).toBeInTheDocument(); expect(writes.open).not.toHaveBeenCalled(); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); const rail = canvasElement.querySelectorAll('.workhub-navigation-item')[1] as HTMLElement; await userEvent.click(rail); await waitFor(() => expect(canvasElement.querySelector('[data-search-highlight="true"]')).toHaveTextContent('请检查发布检查清单。')); - expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4); + expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4); expect(writes.open).not.toHaveBeenCalled(); await userEvent.click(rail); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(1)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(1)); expect(canvas.getByText('请检查发布检查清单。')).toBeInTheDocument(); expect(writes.open).not.toHaveBeenCalled(); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); const answerRail = canvasElement.querySelector('.maka-assistant-answer .workhub-message-rail') as HTMLElement; answerRail.focus(); await userEvent.keyboard('{Enter}'); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(2)); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); await userEvent.click(rail); await waitFor(() => expect(canvasElement.querySelector('[data-search-highlight="true"]')).toHaveTextContent('请检查发布检查清单。')); - expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4); + expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4); (rail.querySelector('button') as HTMLButtonElement).focus(); await userEvent.keyboard('{Enter}'); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(1)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(1)); await userEvent.keyboard('{Enter}'); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); expect(writes.open).not.toHaveBeenCalled(); }, }; @@ -477,7 +478,7 @@ export const FilterWorkConversationsNarrow: Story = { ...FilterWorkConversations export const WorkFilterHoverAndToggle: Story = { render: () => , play: async ({ canvasElement }) => { - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); writes.open.mockClear(); const transcriptElement = canvasElement.querySelector('[data-turn-source-count]'); const stripe = () => canvasElement.querySelector('.maka-user-message .workhub-message-rail') as HTMLElement; @@ -497,18 +498,18 @@ export const WorkFilterHoverAndToggle: Story = { await userEvent.unhover(stripe()); await waitFor(() => expect(color()).toBe(original)); await userEvent.click(stripe()); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(2)); await userEvent.click(canvasElement.querySelector('.maka-assistant-answer .workhub-message-rail') as HTMLElement); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); await userEvent.click(stripe()); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(2)); await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(2)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(2)); await userEvent.click(canvasElement.querySelector('.workhub-navigation-item') as HTMLElement); - await waitFor(() => expect(canvasElement.querySelectorAll('.maka-transcript-turn')).toHaveLength(4)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); expect(writes.open).not.toHaveBeenCalled(); expect(canvasElement.querySelector('[data-turn-source-count]')).toBe(transcriptElement); }, 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 94788f9300..870e9d08f7 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -182,6 +182,23 @@ test('keeps the assistant answer element as a turn settles around it', async () ); }); +test('keeps reasoning expanded when its last neighboring tool is projected away', async () => { + const { container, root } = domRoot(); + const thinking: TurnTimelineItem = { + kind: 'thinking', messageId: 'reason-1', text: 'First observation', live: false, + }; + await renderTurn(root, turnWith([thinking, RUNNING_TOOL, ANSWER])); + const header = container.querySelector('[data-slot="activity-card-header"]'); + assert.ok(header); + await act(() => { header.dispatchEvent(new window.Event('click', { bubbles: true })); }); + assert.equal(header.getAttribute('aria-expanded'), 'true'); + await renderTurn(root, turnWith([thinking, ANSWER])); + const after = container.querySelector('[data-slot="activity-card-header"]'); + assert.ok(after); + assert.ok(after.isSameNode(header)); + assert.equal(after.getAttribute('aria-expanded'), 'true'); +}); + test('redacts secrets before rendering a settled collapsed reasoning preview', async () => { const { container, root } = domRoot(); await renderTurn(root, turnWith([ diff --git a/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx index c579d451b6..d3478878f4 100644 --- a/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx +++ b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx @@ -35,7 +35,7 @@ import { AstryxLocaleProvider } from '../astryx-i18n.js'; import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import { LocaleProvider } from '../locale-context.js'; -import { PromptAnchorRail } from '../prompt-anchor-rail.js'; +import { PromptAnchorRail, type PromptAnchorRailTurn } from '../prompt-anchor-rail.js'; import { TranscriptScrollAuthorityProvider } from '../transcript-scroll-authority.js'; const originalGlobals = { @@ -244,3 +244,37 @@ test('portals unloaded landmarks into the layout host and keeps them actionable' assert.doesNotMatch(mount.innerHTML, /data-resident|Not currently loaded|aria-disabled="true"/); assert.match(mount.innerHTML, /aria-label="Jump to prompt: Prompt 2"/); }); + +test('a retained tick uses updated content, decoration, and navigation callbacks', async () => { + const { mount, window } = harness(); + const root = createRoot(mount); + mountedRoot = root; + const scrollRef = { current: null }; + const turns: PromptAnchorRailTurn[] = Array.from({ length: 3 }, (_, index) => ({ + turnId: `turn-${index}`, label: `Prompt ${index}`, sequence: index, + })); + const calls: string[] = []; + const render = (items: PromptAnchorRailTurn[], navigate: (turn: PromptAnchorRailTurn) => void) => + createElement(LocaleProvider, { + locale: 'en', children: createElement(ChatSurfaceLayout, { + composer: null, children: createElement(PromptAnchorRail, { + turns: items, scrollRef, onNavigateTurn: navigate, + onNavigateStart: () => calls.push('release'), + }), + }), + }); + await act(() => root.render(render(turns, () => calls.push('old')))); + const tick = mount.querySelector('[data-prompt-turn-id="turn-1"]')!; + const updated = turns.map((turn, index) => index === 1 + ? { ...turn, label: 'Updated prompt', reply: 'Updated answer', sequence: 42, highlighted: true } + : turn); + await act(() => root.render(render(updated, (turn) => { + assert.equal(turn, updated[1]); + calls.push(`new:${turn.sequence}`); + }))); + assert.equal(mount.querySelector('[data-prompt-turn-id="turn-1"]'), tick); + assert.equal(tick.getAttribute('aria-label'), 'Jump to prompt: Updated prompt'); + assert.equal(tick.getAttribute('data-highlighted'), 'true'); + await act(() => { tick.dispatchEvent(new window.Event('click', { bubbles: true })); }); + assert.deepEqual(calls, ['release', 'new:42']); +}); diff --git a/packages/ui/src/__tests__/timeline-fold.test.ts b/packages/ui/src/__tests__/timeline-fold.test.ts index b3d040a1dc..d807b22811 100644 --- a/packages/ui/src/__tests__/timeline-fold.test.ts +++ b/packages/ui/src/__tests__/timeline-fold.test.ts @@ -31,7 +31,7 @@ const answer: TurnTimelineItem = { kind: 'text', messageId: 'a', text: 'Fixed' } test('display and copy share reply identity across process, steering and interrupted boundaries', () => { const steering: TurnTimelineItem = { kind: 'user', messageId: 'steer', message: { id: 'steer', role: 'user', text: 'Continue', ts: 2 } }; const partial: TurnTimelineItem = { ...answer, messageId: 'partial', interrupted: true, text: 'Partial' }; - const cases: Array<[TurnTimelineItem[], TurnTimelineItem | undefined]> = [ + const cases: Array<[TurnTimelineItem[], Extract | undefined]> = [ [[commentary, tools], undefined], [[commentary, tools, answer, thinking], answer], [[answer, steering], undefined], diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index 847e952fac..31162acf56 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -180,7 +180,9 @@ describe('tool activity presentation', () => { ); assert.match(markup, /连续操作 11 个控件/); assert.match(markup, /「计算器」窗口/); - assert.match(markup, />7\/117\/11 { + const original = { + document: globalThis.document, window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.querySelector('#root')!; + const root = createRoot(container); + t.after(async () => { await act(() => root.unmount()); Object.assign(globalThis, original); }); + const calls = [0, 1].map(i => ({ + key: String(i), name: 'Read', target: `file-${i}.txt`, + resultDetail: , + })); + const render = async (expanded: boolean) => { + await act(() => root.render()); + }; + const header = () => container.querySelector('[role="button"]')!; + const rows = () => container.querySelectorAll('[data-slot="chat-tool-call-row"]'); + const toggle = async (expanded: boolean) => { + if (controlled) await render(expanded); + else await act(() => { header().dispatchEvent(new window.Event('click', { bubbles: true })); }); + }; + + await render(false); + assert.equal(header().getAttribute('aria-expanded'), 'false'); + assert.equal(rows().length, 0, 'closed group must not create hidden tool rows'); + assert.ok(document.getElementById(header().getAttribute('aria-controls')!), 'animation shell exists before first expansion'); + await toggle(true); + assert.equal(rows().length, 2); + await act(() => { rows()[0]!.dispatchEvent(new window.Event('click', { bubbles: true })); }); + const input = container.querySelector('input')!; + input.value = 'edited'; + await toggle(false); + calls.push({ key: '2', name: 'Read', target: 'file-2.txt', resultDetail: }); + await render(false); + await toggle(true); + assert.equal(rows().length, 3, 'new tools appear after reopening'); + assert.equal(container.querySelector('input'), input, 'group close does not discard mounted detail state'); + assert.equal(input.value, 'edited'); + }); +} diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 988c3961eb..b06577ea3e 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -126,7 +126,9 @@ function withObservers(run: (resize: () => void, frame: () => void, mutate: ( const observers = new Set<() => void>(); const mutations = new Set<() => void>(); const frames: FrameRequestCallback[] = []; - const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; + const globals = globalThis as { CSS?: unknown; ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; + const originalCss = globals.CSS; + globals.CSS = { escape: (value: string) => value }; const originalResize = globals.ResizeObserver; const originalMutation = globals.MutationObserver; const originalFrame = globals.requestAnimationFrame; @@ -161,6 +163,7 @@ function withObservers(run: (resize: () => void, frame: () => void, mutate: ( for (const mutation of [...mutations]) mutation(); }); } finally { + globals.CSS = originalCss; globals.ResizeObserver = originalResize; globals.MutationObserver = originalMutation; globals.requestAnimationFrame = originalFrame; @@ -186,70 +189,100 @@ test('Ctrl and Meta wheel zoom preserve following without requesting history', ( }); }); -test('touch publication waits for the last contact to end or cancel', () => { +test('range publication leaves native input and reading geometry with the browser', () => { withObservers(() => { - for (const end of ['touchend', 'touchcancel'] as const) { + for (const input of ['wheel', 'touch', 'scrollbar'] 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'); + if (input === 'wheel') root.input(-100); + else if (input === 'touch') root.touch('touchstart', 2); + else root.grabScrollbar(); + assert.equal(authority.isInputActive(), true); + authority.commitRange(() => commits++); + assert.equal(commits, 1); + assert.equal(authority.isInputActive(), true, 'publication does not retire native input'); + if (input === 'scrollbar') { + root.ownerDocument.dispatchEvent(new Event('pointerup')); + authority.commitRange(() => commits++); + assert.equal(commits, 2, 'publication remains available after release'); + } 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('explicit navigation during range publication outranks the old reading anchor', () => { + withObservers(() => { + for (const command of ['reveal', 'tail'] as const) { + const root = fakeRoot(); + root.turns = [{ turnId: 'old', top: 0, height: 400 }]; + const anchor = { + isConnected: true, + dataset: { turnId: 'old' }, + getBoundingClientRect: () => ({ top: -root.scrollTop, bottom: 400 - root.scrollTop }), + }; + root.querySelectorAll = () => [anchor]; + Object.assign(root, { querySelector: () => anchor }); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + authority.releasePin(); + root.scrollTop = 0; + authority.commitRange(() => { + if (command === 'tail') authority.pinToTail(); + else authority.revealTurn({ scrollIntoView: () => { root.scrollTop = 1_200; } } as unknown as HTMLElement, + { block: 'start' }); + }); + assert.equal(root.scrollTop, command === 'tail' ? root.scrollHeight - root.clientHeight : 1_200); + detach(); + } }); }); -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('range publication coalesces within a microtask and uses the viewport anchor owner', async () => { + const publication = createTranscriptViewportNavigation(); + const commits: number[] = []; + let scheduled = 0; + publication.attachCommitScheduler('session', { + subscribeToReaderScroll: () => () => {}, + commitRange(commit) { scheduled++; commit(); }, + }); + publication.commitRange('session', () => commits.push(1)); + publication.commitRange('session', () => commits.push(2)); + assert.deepEqual([...commits], []); + await Promise.resolve(); + assert.deepEqual(commits, [2]); + assert.equal(scheduled, 1); +}); + +test('held publication retains only the latest update and drains on settle or detach', async () => { + const publication = createTranscriptViewportNavigation(); + const commits: number[] = []; + let held = true; + let settled!: () => void; + const detach = publication.attachCommitScheduler('session', { + commitRange(commit) { if (!held) commit(); }, + subscribeToReaderScroll(listener) { + settled = () => { listener('settled'); }; + return () => {}; + }, }); + publication.commitRange('session', () => commits.push(1)); + await Promise.resolve(); + publication.commitRange('session', () => commits.push(2)); + await Promise.resolve(); + assert.deepEqual([...commits], []); + held = false; + settled(); + await Promise.resolve(); + assert.deepEqual(commits, [2]); + held = true; + publication.commitRange('session', () => commits.push(3)); + await Promise.resolve(); + detach(); + await Promise.resolve(); + assert.deepEqual(commits, [2, 3], 'closing the viewport cannot strand source publication'); }); test('content that grows under a pinned transcript keeps the tail on screen', () => { @@ -348,27 +381,6 @@ 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 d1ca5298dc..1b6595619d 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -36,6 +36,7 @@ const originalGlobals = { document: globalThis.document, Element: globalThis.Element, HTMLElement: globalThis.HTMLElement, + IntersectionObserver: globalThis.IntersectionObserver, getComputedStyle: globalThis.getComputedStyle, MutationObserver: globalThis.MutationObserver, Node: globalThis.Node, @@ -396,7 +397,7 @@ test('an older request at offset zero does not move the reader', async () => { assert.equal(transcript.scrollTop, 0, 'publication owns anchoring; input must not nudge the reader'); }); -test('idle range admission commits the React DOM before a subsequent input can begin', async () => { +test('range publication commits React synchronously through native input', async () => { const navigation = createTranscriptViewportNavigation(); const { document, window } = parseHTML('
'); const { frames } = installScrollTestEnvironment(document, window); @@ -419,7 +420,7 @@ test('idle range admission commits the React DOM before a subsequent input can b 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'); + 'an admitted update must not remain in React scheduling after publication'); }); await act(async () => { navigation.commitRange('admission', () => publish('held')); @@ -429,8 +430,8 @@ test('idle range admission commits the React DOM before a subsequent input can b }); transcript.scroller.dispatchEvent(down); await Promise.resolve(); - assert.equal(document.querySelector('#mount')!.textContent, 'new', - 'input that starts before admission must hold the queued update'); + assert.equal(document.querySelector('#mount')!.textContent, 'held', + 'an arriving range publishes while the browser owns the reading anchor'); }); await act(() => document.dispatchEvent(new window.Event('pointerup'))); await act(() => { @@ -438,6 +439,33 @@ test('idle range admission commits the React DOM before a subsequent input can b for (const callback of pending) callback(0); }); assert.equal(document.querySelector('#mount')!.textContent, 'held'); + await act(async () => { + const down = new window.Event('pointerdown'); + Object.defineProperties(down, { + button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 3 }, + }); + transcript.scroller.dispatchEvent(down); + navigation.commitRange('admission', () => publish('navigation')); + await Promise.resolve(); + authority.releasePin(); + assert.equal(document.querySelector('#mount')!.textContent, 'navigation'); + document.dispatchEvent(new window.Event('pointerup')); + }); + assert.equal(document.querySelector('#mount')!.textContent, 'navigation', + 'ending physical input publishes navigation even when its gesture was superseded'); + await act(async () => { + const down = new window.Event('pointerdown'); + Object.defineProperties(down, { + button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 2 }, + }); + transcript.scroller.dispatchEvent(down); + navigation.commitRange('admission', () => publish('latest')); + await Promise.resolve(); + assert.equal(document.querySelector('#mount')!.textContent, 'latest'); + authority.pinToTail(); + }); + assert.equal(document.querySelector('#mount')!.textContent, 'latest', + 'explicit tail navigation must not strand a deferred range'); }); test('a source publication survives viewport unmount without another source update', async () => { @@ -461,9 +489,10 @@ test('a source publication survives viewport unmount without another source upda 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)); + await act(() => { + navigation.commitRange('session', () => publish('latest')); + show(false); + }); assert.equal(document.querySelector('#mount')!.textContent, 'latest'); await act(() => show(true)); assert.equal(document.querySelector('#mount')!.textContent, 'latest'); @@ -508,7 +537,7 @@ for (const hasOlder of [false, true]) { }); } })); - assert.equal(publications, 0, 'input holds publication, including an accepted history request'); + assert.equal(publications, 1, 'input must not starve an accepted history request'); await frame(); await frame(); assert.equal(requests, hasOlder ? 1 : 0); assert.equal(publications, 1); @@ -522,7 +551,8 @@ for (const hasOlder of [false, true]) { }); } -test('a held fill publishes before trimming or chaining from the new geometry', async () => { +for (const settlesBeforePublication of [false, true]) { +test(`a fill publishes before eviction when input settles ${settlesBeforePublication ? 'before' : 'after'} publication`, async () => { const navigation = createTranscriptViewportNavigation(); const { document, window } = parseHTML('
'); const { frames } = installScrollTestEnvironment(document, window); @@ -550,6 +580,9 @@ test('a held fill publishes before trimming or chaining from the new geometry', [...transcript.scroller.children].forEach((turn, index) => { (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; }); + // Linkedom has no layout or native scroll anchoring. Model the + // browser retaining turn-0; real geometry is checked in Chromium. + transcript.scroller.scrollTop += 800; }); resolve(true); }; @@ -577,15 +610,24 @@ test('a held fill publishes before trimming or chaining from the new geometry', transcript.scroller.dispatchEvent(new window.Event('scroll')); }); assert.equal(requests, 1); + if (settlesBeforePublication) { + await act(() => document.dispatchEvent(new window.Event('pointerup'))); + await frame(); await frame(); + assert.equal(authority.isInputActive(), false); + assert.deepEqual(retained, [], 'an unfinished read cannot be trimmed using the old window'); + } 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'))); + assert.equal(publications, 1); + if (!settlesBeforePublication) { + assert.deepEqual(retained, [], 'active input still prevents eviction'); + assert.equal(requests, 1, 'the fill does not eagerly chain while input is active'); + await act(() => document.dispatchEvent(new window.Event('pointerup'))); + } await frame(); await frame(); assert.equal(publications, 1); assert.equal(retained.at(-1), 'turn--2', 'the new published band includes the older page'); }); +} test('a transcript change re-reads the band while the reader stays at the tail', async () => { const { document, window } = parseHTML( @@ -638,6 +680,9 @@ test('the retained window is the band around the reader, and an unmounted bookma const transcript = createTranscript(document, window, { clientHeight: 600, turnHeight: 600, turnCount: 20, }); + // Start at the existing reading position. A pending bookmark need not visit + // the tail as a side effect of attaching the scroll authority. + transcript.scroller.scrollTop = 11_400; const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; function Harness({ @@ -837,7 +882,6 @@ test('a session switch restores a Turn anchor after async fill and preserves tai }; const anchors = new Map(); - const handledTargets: number[] = []; const viewportNavigation = createTranscriptViewportNavigation(); const unavailableRestores = new Map(); let authority: TranscriptScrollAuthority | undefined; @@ -857,7 +901,6 @@ test('a session switch restores a Turn anchor after async fill and preserves tai messages: [{ id: `message-${messageRevision}` }] as StoredMessage[], target, restoreTarget, - onTargetHandled: (nonce) => handledTargets.push(nonce), viewportNavigation, onReadingAnchorChange: (turnId) => { unavailableRestores.delete(sessionId); @@ -956,10 +999,10 @@ test('a session switch restores a Turn anchor after async fill and preserves tai await renderSession('session-b'); await flushFrames(); assert.equal(anchors.get('session-b'), 'turn-b-1'); - assert.deepEqual(handledTargets, [1]); + scroller.scrollTop = 100; await renderSession('session-b'); await flushFrames(); - assert.deepEqual(handledTargets, [1]); + assert.equal(scroller.scrollTop, 100, 'a completed navigation must not repeat on render'); target = undefined; // With no resident Turn to re-anchor to, abandoning the restore falls back @@ -1023,7 +1066,6 @@ test('a target lands on the render that mounts its Turn, whatever moved the rang // The Renderer owns the window now: a jump to an unloaded Turn changes the // resident range without touching the message list the shell passes down. const messages = [{ id: 'message-1' }] as StoredMessage[]; - const handledTargets: number[] = []; let highlighted: string | null = null; function Harness() { const scrollRef = useRef(transcript.scroller); @@ -1032,7 +1074,6 @@ test('a target lands on the render that mounts its Turn, whatever moved the rang sessionId: 'session-jump', messages, target: { turnId: 'turn-5', nonce: 7 }, - onTargetHandled: (nonce) => handledTargets.push(nonce), behavior: 'auto', }); highlighted = result.highlightedTurnId; @@ -1053,12 +1094,10 @@ test('a target lands on the render that mounts its Turn, whatever moved the rang await render(); await flushFrames(); assert.equal(highlighted, null, 'a Turn that is not mounted cannot be revealed yet'); - assert.deepEqual(handledTargets, []); transcript.setTurnCount(8); await render(); await flushFrames(); assert.equal(highlighted, 'turn-5'); - assert.deepEqual(handledTargets, [7]); assert.equal(transcript.scrollTop, 3_000, 'the reveal puts the Turn at the top edge'); }); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 70908cb677..de3993072f 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -840,8 +840,8 @@ type ConversationSegment = * What this answer replies to: the steering message that opened it, or * the turn itself for the first answer. This is the segment's identity — * its React key must not be derived from its contents, because those - * change as the turn runs (a Processing fold dissolves once its last - * tools group is projected away) and a changing key remounts the whole + * change as the turn runs (a tools-only sequence disappears when its + * tools are projected away) and a changing key remounts the whole * answer, costing the user their scroll position, any disclosure they * had open, and any text Selection held inside it. * diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 7fc56e87a2..8fed9e93d7 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -259,7 +259,6 @@ export function ChatView(props: { * chat view only scrolls/highlights the already-rendered turn. */ scrollTargetTurn?: { turnId: string; nonce: number; preserveFocus?: boolean }; - onScrollTargetHandled?(nonce: number): void; /** Runtime-only reading position restored without search focus or highlight. */ restoreTargetTurn?: { turnId: string; unavailable?: boolean }; viewportNavigation?: TranscriptViewportNavigation; @@ -516,17 +515,11 @@ export function ChatView(props: { } const scrollRef = chatLayout.scrollContainerRef; const scrollAuthority = useTranscriptScrollAuthority(); - // A rail click aims itself: it puts the prompt at the top of the scrollport - // and holds it there while the loaded range settles. Asking the shell to load - // an unloaded prompt also publishes a scroll target, and that reveal centres - // the turn with the app's scroll motion — a second answer to "where should - // this turn sit", and an animated one, which walks the prompt back off the - // top for a second after the rail has landed it. The reveal keeps its other - // job of recording the reading position; it just has to agree with the rail - // about the edge. + // The rail uses the same semantic navigation as search, but asks the shared + // reveal to place the prompt at the top without animation. const railClaimRef = useRef(undefined); - const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { - if (!turnIdsRef.current.has(turn.turnId) && turn.sequence !== undefined) { + const navigatePromptRail = useCallback((turn: PromptAnchorRailTurn) => { + if (turn.sequence !== undefined) { railClaimRef.current = { turnId: turn.turnId }; loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); } @@ -565,7 +558,6 @@ export function ChatView(props: { messages: props.messages, target: scrollTargetTurn, restoreTarget: props.restoreTargetTurn, - onTargetHandled: props.onScrollTargetHandled, viewportNavigation: props.viewportNavigation, onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, @@ -753,7 +745,7 @@ export function ChatView(props: { turns={promptRailTurns} onHighlightTurn={props.onPromptRailHighlight ? (turn) => props.onPromptRailHighlight?.(turn?.turnId) : undefined} scrollRef={scrollRef} - onNavigateFallback={navigatePromptRailFallback} + onNavigateTurn={navigatePromptRail} onNavigateStart={scrollAuthority.releasePin} /> ` so the - * answer reads cleanly but the thinking is one click away when the - * user wants to verify the chain of reasoning. - */ - assistantThinking?: string; /** * Interleaved thinking / answer / tool / steering sequence in production order — the * rendering source of truth for the turn body. Built from the per-step @@ -822,7 +815,7 @@ export function materializeTurns( } } else if (message.type === "assistant") { // A turn now holds one AssistantMessage per model step. Concatenate their - // text (and thinking) in step order so the turn reads as one answer; keep + // text in step order for aggregate consumers; keep // the first step's id as the stable anchor, and advance ts to the latest // step so durationMs measures to the turn's final assistant message. const priorText = turn.assistant?.text ?? ""; @@ -839,11 +832,6 @@ export function materializeTurns( ts: message.ts, }; turn.modelId = message.modelId; - if (message.thinking?.text) { - turn.assistantThinking = turn.assistantThinking - ? `${turn.assistantThinking}\n\n${message.thinking.text}` - : message.thinking.text; - } // Time-to-answer measured from the earliest message in this turn (usually // the user's send) to the turn's final assistant message ts. Tool runs are // inside this window, so the same metric captures both LLM latency and tool diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index afe1e9100b..295e4ca6d7 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -19,6 +19,7 @@ import { createContext, + useCallback, useContext, memo, useEffect, @@ -117,8 +118,8 @@ export interface PromptAnchorRailProps { onHighlightTurn?: (turn: PromptAnchorRailTurn | undefined) => void; turns: readonly PromptAnchorRailTurn[]; scrollRef: RefObject; - /** When the indexed Turn is outside the Host's active transcript range. */ - onNavigateFallback?: (turn: PromptAnchorRailTurn) => void; + /** Owns indexed navigation, including superseding pending range reads. */ + onNavigateTurn?: (turn: PromptAnchorRailTurn) => void; /** * Stop following the tail, before a jump scrolls. * @@ -161,7 +162,7 @@ export function selectPromptRailTick(input: { export const PromptAnchorRailHostContext = createContext(null); /** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */ -export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart, onHighlightTurn }: PromptAnchorRailProps): React.ReactElement | null { +export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateTurn, onNavigateStart, onHighlightTurn }: PromptAnchorRailProps): React.ReactElement | null { const host = useContext(PromptAnchorRailHostContext); const copy = getConversationCopy(useUiLocale()).sessions; const authority = useTranscriptScrollAuthority(); @@ -272,14 +273,16 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe return observeActivePromptRailVisibility(rail); }, [orderedTurnIds, host]); - function jumpTo(turn: PromptAnchorRailTurn): void { + const jumpTo = useCallback((turn: PromptAnchorRailTurn): void => { const root = scrollRef.current; const el = root?.querySelector(`[data-turn-id="${CSS.escape(turn.turnId)}"]`); // Before the scroll, not after: the tail has to be released while the // transcript is still where the reader left it, or the release lands after // the next growth has already written the view back to the bottom. onNavigateStart?.(); - if (el && 'scrollIntoView' in el) { + if (turn.sequence !== undefined && onNavigateTurn) { + onNavigateTurn(turn); + } else if (el && 'scrollIntoView' in el) { // Instant, whatever the app's scroll-motion policy says. A jump is a // teleport the reader asked for, not a journey — and an animated one // does not survive this surface: traced against a 30-prompt session, the @@ -288,9 +291,14 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe // unreliably. (el as HTMLElement).scrollIntoView({ behavior: 'auto', block: 'start' }); } else if (!el) { - onNavigateFallback?.(turn); + onNavigateTurn?.(turn); } - } + }, [scrollRef, onNavigateStart, onNavigateTurn]); + + const hoverTurn = useCallback((turn: PromptAnchorRailTurn, index: number) => { + setHoveredIndex(index); + onHighlightTurn?.(turn); + }, [onHighlightTurn]); // A rail is only useful once there are a few prompts to jump between. if (railTurns.length < 3 || !host) return null; @@ -315,52 +323,22 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe > {railTurns.map((turn, index) => { const isActive = turn.turnId === activeRailTurnId; - const preview = turn.label.trim() || copy.emptyPrompt; - const replyPreview = (turn.reply ?? '').replace(/\s+/g, ' ').trim().slice(0, 140); const proximity = hoveredIndex === null ? HOVER_FALLOFF_TICKS : Math.min(Math.abs(index - hoveredIndex), HOVER_FALLOFF_TICKS); const scale = (14 + ((HOVER_FALLOFF_TICKS - proximity) * 3)) / 26; return ( - - {preview} - {replyPreview ? ( - {replyPreview} - ) : null} - - } - > - - + turn={turn} + index={index} + isActive={isActive} + scale={scale} + onNavigate={jumpTo} + onHover={hoverTurn} + onHighlight={onHighlightTurn} + /> ); })} @@ -368,3 +346,56 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe ); return createPortal(rail, host); }); + +// Reading-position updates change the active ticks, not every preview. Keep +// each tick's interaction tree reusable; content and locale changes still render. +const PromptRailTick = memo(function PromptRailTick({ + turn, index, isActive, scale, onNavigate, onHover, onHighlight, +}: { + turn: PromptAnchorRailTurn; + index: number; + isActive: boolean; + scale: number; + onNavigate(turn: PromptAnchorRailTurn): void; + onHover(turn: PromptAnchorRailTurn, index: number): void; + onHighlight: PromptAnchorRailProps['onHighlightTurn']; +}) { + const copy = getConversationCopy(useUiLocale()).sessions; + const preview = turn.label.trim() || copy.emptyPrompt; + const replyPreview = (turn.reply ?? '').replace(/\s+/g, ' ').trim().slice(0, 140); + return ( + + {preview} + {replyPreview ? {replyPreview} : null} + + } + > + + + ); +}); diff --git a/packages/ui/src/timeline-fold.ts b/packages/ui/src/timeline-fold.ts index 4a1f641bcc..5b0d20b562 100644 --- a/packages/ui/src/timeline-fold.ts +++ b/packages/ui/src/timeline-fold.ts @@ -32,7 +32,7 @@ export interface ProcessingFold { children: FoldedTimelineChild[]; } -export type FoldedTimelineEntry = TurnTimelineItem | ProcessingFold; +export type FoldedTimelineEntry = Extract | ProcessingFold; export function foldTimeline(items: readonly TurnTimelineItem[]): { entries: FoldedTimelineEntry[]; diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index ea214de8df..0b897448a6 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -61,11 +61,12 @@ export interface TranscriptScrollSnapshot { } export interface TranscriptScrollAuthority { - /** Whether native input still holds the published geometry. */ + /** Whether native input is still active; window eviction waits for it. */ isInputActive(): boolean; - /** Synchronously publish and preserve geometry if native input permits it. */ - commitIfIdle(commit: () => void): boolean; - subscribeToIdle(listener: () => void): () => void; + /** Publish with a reading anchor, or leave publication pending during a thumb hold. */ + commitRange(commit: () => void): void; + /** Explicit navigation outranks preservation during the same publication. */ + revealTurn(element: HTMLElement, options: ScrollIntoViewOptions): 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. */ @@ -115,34 +116,31 @@ 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; + let revealVersion = 0; 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 { + const version = revealVersion; + if (pinned) { 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'; + if (version === revealVersion) writeToTail(); + return; + } + // At the scroll origin Chromium does not establish a native anchor. Keep + // the existing first row there; everywhere else native anchoring owns the + // correction, including the pressed thumb's input origin. + const anchor = target.scrollTop === 0 + ? target.querySelector('[data-turn-id]') : null; + const anchorId = anchor?.dataset.turnId; + const anchorTop = anchor?.getBoundingClientRect().top; + flushSync(commit); + if (pinned || version !== revealVersion || root !== target || anchorTop === undefined) return; + if (anchor?.isConnected && anchor.dataset.turnId === anchorId) { + target.scrollTop += anchor.getBoundingClientRect().top - anchorTop; } - }; - const notifyIdle = (): void => { - if (isInputActive()) return; - for (const listener of [...idleListeners]) listener(); }; let readingTurnId: string | undefined; let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail, readingTurnId }; @@ -183,14 +181,10 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { return { isInputActive, - commitIfIdle(commit) { - if (isInputActive()) return false; - commitRange(commit); - return true; - }, - subscribeToIdle(listener) { - idleListeners.add(listener); - return () => { idleListeners.delete(listener); }; + commitRange, + revealTurn(element, options) { + revealVersion += 1; + element.scrollIntoView(options); }, attach(next) { root = next; @@ -241,18 +235,22 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { if (pointer === event.pointerId) gesture ??= { top: target.scrollTop }; }; const onPointerUp = (): void => { + if (pointer === undefined) return; pointer = undefined; onScrollEnd(); const pending = gesture; - if (!pending || pending.direction !== undefined) return; + // Navigation can retire the gesture while native input remains held. + // Its end still releases publication; gesture history is not ownership. + if (!pending) { reportReader('settled'); return; } + if (pending.direction !== undefined) return; // Native track clicks can start their smooth scroll after pointerup. // Scroll steps precede rAF; retire a click that still has not moved // there, rather than leaving a non-scrolling click armed indefinitely. requestAnimationFrame(() => { if (gesture !== pending || pending.direction !== undefined) return; gesture = undefined; - notifyIdle(); if (pinned) writeToTail(); + reportReader('settled'); }); }; let touchY: number | undefined; @@ -300,9 +298,6 @@ 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; @@ -316,13 +311,12 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // the pin. Settling an unmoved edge gesture must not release it too. pinned = pinned || (ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX); gesture = undefined; - 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'); + reportReader('settled'); })); }; target.addEventListener('wheel', onWheel, { passive: true }); @@ -389,20 +383,19 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { }; }, pinToTail() { + const endedInput = isInputActive(); gesture = undefined; pointer = undefined; touchHeld = false; pinned = true; - queueMicrotask(notifyIdle); writeToTail(); publish(); + // Tail navigation can run in a React effect; publish after that commit. + if (endedInput) queueMicrotask(() => reportReader('settled')); }, 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 bc731ed286..ffe42a7978 100644 --- a/packages/ui/src/transcript-viewport-navigation.ts +++ b/packages/ui/src/transcript-viewport-navigation.ts @@ -17,11 +17,13 @@ * under the License. */ +import type { TranscriptScrollAuthority } from './transcript-scroll-authority.js'; + /** 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 viewport: { sessionId: string; commitRange: (commit: () => void) => void } | undefined; let pending: { sessionId: string; commit: () => void } | undefined; const drain = (): void => { const update = pending; @@ -30,17 +32,16 @@ export function createTranscriptViewportNavigation() { pending = undefined; update.commit(); }; - if (viewport?.sessionId === update.sessionId) viewport.commitIfIdle(commit); + if (viewport?.sessionId === update.sessionId) viewport.commitRange(commit); else commit(); }; return { - attachCommitScheduler(sessionId: string, authority: { - commitIfIdle(commit: () => void): boolean; - subscribeToIdle(listener: () => void): () => void; - }): () => void { - const attached = { sessionId, commitIfIdle: authority.commitIfIdle }; + attachCommitScheduler(sessionId: string, authority: Pick): () => void { + const attached = { sessionId, commitRange: authority.commitRange }; viewport = attached; - const unsubscribe = authority.subscribeToIdle(drain); + const unsubscribe = authority.subscribeToReaderScroll((phase) => { + if (phase === 'settled') drain(); + }); queueMicrotask(drain); return () => { unsubscribe(); diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 4d97648d7f..c5ef054ba7 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -31,7 +31,7 @@ * compensates for content that lands above them; that belongs to the authority. */ -import { useEffect, useRef, useState, type RefObject } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; @@ -48,7 +48,6 @@ export function useChatScroll(input: { */ target?: { turnId: string; nonce: number; preserveFocus?: boolean; align?: 'start' | 'center' }; restoreTarget?: { turnId: string; unavailable?: boolean }; - onTargetHandled?(nonce: number): void; viewportNavigation?: TranscriptViewportNavigation; onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; @@ -68,8 +67,6 @@ export function useChatScroll(input: { const handledTarget = useRef(null); const anchorChangeRef = useRef(input.onReadingAnchorChange); anchorChangeRef.current = input.onReadingAnchorChange; - const targetHandledRef = useRef(input.onTargetHandled); - targetHandledRef.current = input.onTargetHandled; const reportReadingAnchor = useRef<(() => void) | undefined>(undefined); const reportedAnchor = useRef<{ sessionId?: string; turnId?: string } | undefined>(undefined); const activation = useRef<{ sessionId?: string; restoreTurnId?: string } | undefined>(undefined); @@ -197,13 +194,14 @@ export function useChatScroll(input: { const check = (): void => { if (!root.isConnected || bandCheck.current !== check) return; const screen = Math.max(320, root.clientHeight); - const above = root.scrollTop; - const below = root.scrollHeight - root.clientHeight - root.scrollTop; + const above = Math.max(0, root.scrollTop); + const below = Math.max(0, 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; + // A read owns its pending range until publication finishes. Input may + // settle first; trimming from the old DOM then discards incoming rows. + // The read completion rechecks this band against the published window. + if (authority.isInputActive() || inFlight.up || inFlight.down) return; if (above <= screen * 6 && below <= screen * 6) return; const rect = root.getBoundingClientRect(); const turns = [...root.querySelectorAll('[data-turn-id]')]; @@ -217,9 +215,12 @@ export function useChatScroll(input: { ? commandTargetTurnId.current : undefined; if (pending && turns.some((turn) => turn.dataset.turnId === pending)) return; + const selection = root.ownerDocument.getSelection?.(); const kept = turns.filter((turn) => { const box = turn.getBoundingClientRect(); - return box.bottom >= rect.top - screen * 4 && box.top <= rect.bottom + screen * 4; + return (box.bottom >= rect.top - screen * 4 && box.top <= rect.bottom + screen * 4) + || turn.contains(root.ownerDocument.activeElement) + || Boolean(selection && !selection.isCollapsed && selection.containsNode(turn, true)); }); const first = kept[0]?.dataset.turnId; const last = kept.at(-1)?.dataset.turnId; @@ -241,12 +242,19 @@ export function useChatScroll(input: { // leaves the pin and the reading Turn alone reaches nothing but this. const size = new ResizeObserver(() => check()); size.observe(root); + // The retained window also protects focus and selection. Releasing either + // makes distant rows eligible for the same existing eviction pass. + const afterFocus = () => queueMicrotask(check); + root.addEventListener('focusout', afterFocus); + root.ownerDocument.addEventListener('selectionchange', check); const frame = window.requestAnimationFrame(check); return () => { window.cancelAnimationFrame(frame); if (bandCheck.current === check) bandCheck.current = undefined; stopWatchingReader(); size.disconnect(); + root.removeEventListener('focusout', afterFocus); + root.ownerDocument.removeEventListener('selectionchange', check); }; }, [authority, input.hasOlderHistory, input.hasNewerHistory, canPrefetch, input.scrollRef, input.sessionId]); @@ -256,7 +264,7 @@ export function useChatScroll(input: { return () => window.cancelAnimationFrame(frame); }, [input.messages]); - useEffect(() => { + useLayoutEffect(() => { const explicitTarget = input.target?.turnId ? { kind: 'search' as const, @@ -287,7 +295,7 @@ export function useChatScroll(input: { : restoreCommandKey(input.sessionId, target.turnId, target.unavailable); if (handledTarget.current === chosen) return; authority.releasePin(); - const frame = window.requestAnimationFrame(() => { + const reveal = () => { if (commandTarget.current !== chosen) return; // A reader who asked for the tail while this frame was queued outranks it: // the bookmark describes where they were, the pin where they said to be. @@ -309,7 +317,7 @@ export function useChatScroll(input: { handledTarget.current = chosen; const targetElement = element as HTMLElement; const alignToStart = target.kind !== 'search' || target.align === 'start'; - targetElement.scrollIntoView({ + authority.revealTurn(targetElement, { // A reveal that agrees with a requester already aiming this turn has to // be instant too: an animated one is a second writer moving the // scroller for a second after the requester has landed it. @@ -326,15 +334,18 @@ export function useChatScroll(input: { targetElement.focus({ preventScroll: true }); } setHighlightedTurnId(target.turnId); - targetHandledRef.current?.(target.nonce); - }); + }; + // A newly published range and its explicit reveal must reach the screen + // together. Only initial attachment waits for the ancestor's ref. + const frame = input.scrollRef.current ? undefined : window.requestAnimationFrame(reveal); + if (input.scrollRef.current) reveal(); const clear = target.kind === 'search' ? window.setTimeout(() => { setHighlightedTurnId((current) => (current === target.turnId ? null : current)); }, 2200) : undefined; return () => { - window.cancelAnimationFrame(frame); + if (frame !== undefined) window.cancelAnimationFrame(frame); if (clear !== undefined) window.clearTimeout(clear); }; }); diff --git a/patches/@astryxdesign+core+0.5.2.patch b/patches/@astryxdesign+core+0.5.2.patch index a08e3923df..40b7ccf71d 100644 --- a/patches/@astryxdesign+core+0.5.2.patch +++ b/patches/@astryxdesign+core+0.5.2.patch @@ -96,10 +96,9 @@ index ff9b9fa..f681cd0 100644 const newMsgs = useChatNewMessages({ isLocked: scroll.isLocked, diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js b/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js -index 889970f..459e6e8 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js -@@ -122,6 +122,7 @@ function CallRow({ +@@ -122,6 +122,7 @@ const row = /*#__PURE__*/_jsxs("div", { role: hasDetail ? 'button' : undefined, tabIndex: hasDetail ? 0 : undefined, @@ -107,6 +106,26 @@ index 889970f..459e6e8 100644 "aria-expanded": hasDetail ? isDetailOpen : undefined, "aria-controls": hasDetail && isDetailOpen ? detailId : undefined, onClick: toggleDetail, +@@ -278,6 +279,10 @@ + const contentId = useId(); + const isControlled = controlledExpanded !== undefined; + const isExpanded = isControlled ? controlledExpanded : internalExpanded; ++ // Defer hidden rows until first use. Keep them after closing so the grid ++ // animation and each row's expanded detail state survive a group toggle. ++ const [hasExpanded, setHasExpanded] = useState(isExpanded); ++ if (isExpanded && !hasExpanded) setHasExpanded(true); + const toggle = useCallback(() => { + const next = !isExpanded; + if (!isControlled) { +@@ -417,7 +422,7 @@ + ...{ + className: "x78zum5 xdt5ytf x7a5moj xtedp8i" + }, +- children: calls.map(call => /*#__PURE__*/_jsx(CallRow, { ++ children: (isExpanded || hasExpanded) && calls.map(call => /*#__PURE__*/_jsx(CallRow, { + call: call + }, getToolCallKey(call))) + }) diff --git a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js index e1d2ebd..1f8337e 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js @@ -500,9 +519,18 @@ index 3d2cf00..c5c0d55 100644 role: "group", "aria-labelledby": `${id}-label`, diff --git a/node_modules/@astryxdesign/core/dist/astryx.css b/node_modules/@astryxdesign/core/dist/astryx.css -index 0159f32..654948b 100644 +index 0159f32..4067864 100644 --- a/node_modules/@astryxdesign/core/dist/astryx.css +++ b/node_modules/@astryxdesign/core/dist/astryx.css +@@ -564,7 +564,7 @@ + .xk2sxw7:focus-within:not(#\#):not(#\#){border-color:var(--color-error)} + .xyq33ac:focus-within:not(#\#):not(#\#){border-color:var(--color-success)} + .xa1yw2k:focus-within:not(#\#):not(#\#){border-color:var(--color-warning)} +- .xafswnp.xafswnp:where(.x1iwu4tg:has(:focus-visible) *):not(#\#):not(#\#){outline:var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color)} ++ :where(.x1iwu4tg:has(:focus-visible)) .xafswnp.xafswnp:not(#\#):not(#\#){outline:var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color)} + .x1dordxg:focus-within:not(#\#):not(#\#){overflow:visible} + .x16s19ga:has(:focus-visible):not(#\#):not(#\#){outline:var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color)} + .x64lkcz:focus:not(#\#):not(#\#){border-color:var(--color-error)} @@ -1280,6 +1280,7 @@ .x1yn0g08:not(#\#):not(#\#):not(#\#){word-break:break-all} .x13faqbe:not(#\#):not(#\#):not(#\#){word-break:break-word} @@ -511,6 +539,26 @@ index 0159f32..654948b 100644 .x1ja2u2z:not(#\#):not(#\#):not(#\#){z-index:0} .x1vjfegm:not(#\#):not(#\#):not(#\#){z-index:1} .xfo81ep:not(#\#):not(#\#):not(#\#){z-index:1000} +@@ -1321,7 +1322,7 @@ + .x7s97pk:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:2px} + .x13aywxo:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--button-focus-offset)} + .x1vwwbsn:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)} +- .xwz5zln.xwz5zln:where(.x1iwu4tg:has(:focus-visible) *):not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)} ++ :where(.x1iwu4tg:has(:focus-visible)) .xwz5zln.xwz5zln:not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)} + .x1jhp3zv:focus-visible:not(#\#):not(#\#):not(#\#){outline-style:var(--focus-outline-style)} + .x1vidyx5:focus-visible:not(#\#):not(#\#):not(#\#){outline-width:var(--focus-outline-width)} + .x1n6uwha:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){padding-inline-end:max(var(--spacing-4),env(safe-area-inset-left,0px))} +@@ -1780,8 +1781,8 @@ + .x5lhr3w:not(#\#):not(#\#):not(#\#):not(#\#){width:var(--x-width)} + .x1wp2rvj.x1wp2rvj:where(.x139im0d:last-child *):not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-width:0} + .x132qfvm:focus-within:not(#\#):not(#\#):not(#\#):not(#\#){height:auto} +- .xsg11nj.xsg11nj:where(.x-default-marker:has(> .astryx-layout-footer:not([data-divider])) *):not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:0} +- .x1ioh5az.x1ioh5az:where(.x-default-marker:has(> .astryx-layout-header:not([data-divider])) *):not(#\#):not(#\#):not(#\#):not(#\#){padding-top:0} ++ :where(.x-default-marker:has(> .astryx-layout-footer:not([data-divider]))) .xsg11nj.xsg11nj:not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:0} ++ :where(.x-default-marker:has(> .astryx-layout-header:not([data-divider]))) .x1ioh5az.x1ioh5az:not(#\#):not(#\#):not(#\#):not(#\#){padding-top:0} + .x1kw28su:focus-within:not(#\#):not(#\#):not(#\#):not(#\#){padding-top:var(--spacing-1)} + .x10okhzq:focus-within:not(#\#):not(#\#):not(#\#):not(#\#){width:auto} + .x5du68j:has(~ * [data-chat-name]):not(#\#):not(#\#):not(#\#):not(#\#){margin-top:var(--spacing-5)} diff --git a/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.d.ts b/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.d.ts index 7a35d1c..50ee70b 100644 --- a/node_modules/@astryxdesign/core/dist/hooks/useStreamingText.d.ts @@ -743,10 +791,9 @@ index d45a912..efec9fd 100644 WebkitMaskImage: 'linear-gradient(to bottom, transparent, black 48px)', }, diff --git a/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx b/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx -index f22bb1f..b710951 100644 --- a/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx +++ b/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx -@@ -399,6 +399,7 @@ function CallRow({call}: {call: ChatToolCallItem}) { +@@ -399,6 +399,7 @@