diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 8bd366b228..61961c3c59 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -66,8 +66,8 @@ "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" }, "transcript-scroll-cost.spec.ts": { - "tests": 3, - "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping" + "tests": 4, + "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping; prompt-rail navigation and history prepend cross the renderer, preload and main-process transcript range boundary" }, "workhub-layout.spec.ts": { "tests": 2, diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 4a9dcb1530..49f71d6c2e 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -230,6 +230,66 @@ test('the browser skips the Turns the reader has scrolled past', async ({ expect((await sample(page)).skippedTurns).toBeGreaterThan(0); }); +test('a large upward gesture after a prompt-rail jump stays continuous', async ({ + promptRailWindow: page, +}) => { + const wheelDelta = 80; + const wheelTicks = 24; + await page.setViewportSize({ width: 1_000, height: 700 }); + const ticks = page.locator('.maka-prompt-rail-tick[data-prompt-turn-id]'); + const tick = ticks.nth(Math.floor(await ticks.count() / 2)); + const turnId = await tick.getAttribute('data-prompt-turn-id'); + expect(turnId).not.toBeNull(); + + await tick.click(); + const destination = page.locator(`[data-turn-id="${turnId}"]`); + await expect(destination).toHaveCount(1); + await expect.poll(async () => page.evaluate((id) => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + const turn = document.querySelector(`[data-turn-id="${CSS.escape(id)}"]`); + if (!root || !turn) return null; + return turn.getBoundingClientRect().top - root.getBoundingClientRect().top; + }, turnId!)).toBe(0); + + const turns = page.locator('[data-turn-id]'); + const firstTurnBefore = await turns.first().getAttribute('data-turn-id'); + expect(firstTurnBefore).not.toBe(turnId); + const cdp = await page.context().newCDPSession(page); + const box = await page.locator(SCROLLER).boundingBox(); + if (!box) throw new Error('the chat scroll container has no box'); + const visibleTurnTops = () => page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + if (!root) return []; + const rootRect = root.getBoundingClientRect(); + return [...root.querySelectorAll('[data-turn-id]')].flatMap((turn) => { + const rect = turn.getBoundingClientRect(); + return rect.bottom > rootRect.top && rect.top < rootRect.bottom + ? [{ id: turn.dataset.turnId!, top: rect.top - rootRect.top }] + : []; + }); + }); + for (let index = 0; index < wheelTicks; index += 1) { + const before = await visibleTurnTops(); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: box.x + box.width / 2, + y: box.y + box.height / 2, + deltaX: 0, + deltaY: -wheelDelta, + }); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); + const after = new Map((await visibleTurnTops()).map((turn) => [turn.id, turn.top])); + const retained = before.find((turn) => after.has(turn.id)); + expect(retained, 'each input step must retain visible transcript content').toBeDefined(); + expect(Math.abs(after.get(retained!.id)! - retained!.top)) + .toBeLessThanOrEqual(wheelDelta + 2); + } + + await expect.poll(() => turns.first().getAttribute('data-turn-id')).not.toBe(firstTurnBefore); +}); + /** * The bound the Desktop transcript is built on: paging back through a history * far longer than the active range mounts a bounded number of Turns, not a 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 6c84432260..27862e4a79 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 @@ -1222,6 +1222,7 @@ test('loads a history target with newer messages available below it', async () = })); const bootstrapPage = transcriptPage('older', null, 4); const aroundPage = transcriptPage('newer', 'newer', 4); + const beforeStartPage = olderProbePage(0, false); const inputs: Array<{ direction: string; anchorSequence: number | null }> = []; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), @@ -1236,10 +1237,12 @@ test('loads a history target with newer messages available below it', async () = loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (page) => page === bootstrapPage ? { messages: messages.slice(4), nextCursor: null } - : { messages: messages.slice(0, 3), nextCursor: 'newer' }, + : page === beforeStartPage + ? { messages: [], nextCursor: null } + : { messages: messages.slice(0, 3), nextCursor: 'newer' }, loadTranscriptPage: async (input) => { inputs.push(input); - return input.direction === 'older' ? olderProbePage(0, false) : aroundPage; + return input.direction === 'older' ? beforeStartPage : aroundPage; }, async close() {}, }); @@ -1273,6 +1276,8 @@ test('keeps an oversized transcript sparse while moving between indexed prompts' const bootstrapPage = transcriptPage('older', 'older', 15); const historicalPage = transcriptPage('newer', 'newer', 15); const intermediatePage = transcriptPage('newer', 'newer', 15); + const beforeStartPage = transcriptPage('older', null, 15); + const beforeIntermediatePage = transcriptPage('older', 'older', 15); const latestPage = transcriptPage('older', 'older', 15); const requests: Array<{ direction: 'older' | 'newer'; @@ -1294,6 +1299,10 @@ test('keeps an oversized transcript sparse while moving between indexed prompts' loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (page) => page === bootstrapPage || page === latestPage ? { messages: messages.slice(12, 16), nextCursor: 'older' } + : page === beforeStartPage + ? { messages: [], nextCursor: null } + : page === beforeIntermediatePage + ? { messages: messages.slice(1, 6), nextCursor: 'older' } : page === historicalPage ? { messages: messages.slice(0, 5), nextCursor: 'newer' } : { messages: messages.slice(6, 11), nextCursor: 'newer' }, @@ -1304,8 +1313,9 @@ test('keeps an oversized transcript sparse while moving between indexed prompts' maxBytes: input.maxBytes, }); if (input.direction === 'older') { - if (input.maxBytes > 1) return latestPage; - return olderProbePage(input.anchorSequence!, input.anchorSequence !== 0); + if (input.anchorSequence === 0) return beforeStartPage; + if (input.anchorSequence === 6) return beforeIntermediatePage; + return latestPage; } return input.anchorSequence === null ? historicalPage : intermediatePage; }, @@ -1338,8 +1348,8 @@ test('keeps an oversized transcript sparse while moving between indexed prompts' assertRangeFitsBudget(rendererStore); await replica.loadAround(6, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [6, 7, 8, 9, 10]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 4', 'Prompt 5', 'Prompt 6']); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [4, 5, 6, 7]); + assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 3', 'Prompt 4']); assert.equal(rendererStore.range().hasOlder, true); assert.equal(rendererStore.range().hasNewer, true); assertRangeFitsBudget(rendererStore); @@ -1351,13 +1361,13 @@ test('keeps an oversized transcript sparse while moving between indexed prompts' assert.equal(rendererStore.range().hasNewer, false); assertRangeFitsBudget(rendererStore); - // Every jump that is not to the tail pays one extra single-byte read, the - // only thing that can say whether the anchor has anything older than it. + // Every jump that is not to the tail reads both sides of the target. The + // resident budget then keeps the target and the nearest complete Turns. assert.deepEqual(requests, [ { direction: 'newer', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - { direction: 'older', anchorSequence: 0, maxBytes: 1 }, + { direction: 'older', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, { direction: 'newer', anchorSequence: 5, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - { direction: 'older', anchorSequence: 6, maxBytes: 1 }, + { direction: 'older', anchorSequence: 6, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, { direction: 'older', anchorSequence: 16, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, ]); replica.close(); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts index 2dff394ff4..df670a96c4 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -239,12 +239,14 @@ test('superseded batches remain ACKable and cannot reset the latest range while const firstOldBatch = deferred(); const eventsClosed = deferred(); const bootstrap = page(1); - const historyPage = page(1); + const historyPage = { ...page(1), direction: 'newer' as const }; + const beforeStartPage = page(1); const latestPage = page(1); const old = record(0); const largeOld = { ...old, message: { ...old.message, text: 'A'.repeat(700 * 1024) } as StoredMessage }; const latest = record(1); const blocked: DesktopTranscriptBatch[] = []; + const requests: Array<{ direction: string; anchorSequence: number | null }> = []; let releaseAcks = false; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => runtimeHostSessionFixture({ @@ -255,11 +257,22 @@ test('superseded batches remain ACKable and cannot reset the latest range while durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, }, loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => ({ - messages: candidate === historyPage ? [largeOld] : [latest], - nextCursor: candidate === historyPage ? 'newer' : 'older', - }), - loadTranscriptPage: async (request) => request.direction === 'newer' ? historyPage : latestPage, + decodeTranscriptPage: async (candidate) => candidate === beforeStartPage + ? { messages: [], nextCursor: null } + : { + messages: candidate === historyPage ? [largeOld] : [latest], + nextCursor: candidate === historyPage ? 'newer' : 'older', + }, + loadTranscriptPage: async (request) => { + requests.push({ direction: request.direction, anchorSequence: request.anchorSequence ?? null }); + if (request.direction === 'newer') { + assert.equal(request.anchorSequence, null); + return historyPage; + } + if (request.anchorSequence === 0) return beforeStartPage; + assert.equal(request.anchorSequence, 2); + return latestPage; + }, async close() { eventsClosed.resolve(); }, }) }, emitSessionsChanged() {}, @@ -287,6 +300,11 @@ test('superseded batches remain ACKable and cannot reset the latest range while releaseAcks = true; for (const batch of blocked) ack(batch); await Promise.all([history, following]); + assert.deepEqual(requests, [ + { direction: 'newer', anchorSequence: null }, + { direction: 'older', anchorSequence: 0 }, + { direction: 'older', anchorSequence: 2 }, + ]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); const snapshot = store.snapshot(); for (const batch of blocked) assert.equal(store.accept(batch), false); 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 b56014c5d2..ceedaf6166 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -63,6 +63,11 @@ test('durable tail advancement preserves an explicitly selected oversized histor try { await fixture.replica.loadAround(0, PAGE_BYTES); assert.deepEqual(sequences(fixture.replica), [0, 1]); + assert.equal(fixture.replica.snapshot().hasOlder, false); + assert.deepEqual(fixture.requests, [ + { direction: 'newer', anchorSequence: null, throughSequence: 3 }, + { direction: 'older', anchorSequence: 0, throughSequence: 3 }, + ]); fixture.requests.length = 0; await fixture.replica.advance(4); @@ -455,15 +460,23 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { const through = request.throughSequence ?? 4; const anchor = request.anchorSequence ?? null; requests.push({ direction: request.direction, anchorSequence: anchor, throughSequence: through }); - const history = request.direction === 'older' ? anchor === 2 : anchor === null; + const available = records.filter(({ identity }) => identity <= through && ( + anchor === null || (request.direction === 'older' ? identity < anchor : identity > anchor) + )); + const history = request.direction === 'older' + ? anchor !== null && anchor <= 2 + : anchor === null; + // Keep the fixture's oversized first Turn on its own page, but never + // return records on the wrong side of the requested exclusive anchor. + const selected = history ? available.filter(({ identity }) => identity < 2) + : request.direction === 'older' ? available.filter(({ identity }) => identity >= 2) + : available; return page({ direction: request.direction, through, - records: history ? records.slice(0, 2) - : request.direction === 'older' ? records.slice(2, through + 1) - : records.slice((anchor ?? -1) + 1, through + 1), - hasMore: history ? request.direction === 'newer' : request.direction === 'older', - protectedSequence: history ? 0 : through >= 5 ? 5 : 2, + records: selected, + hasMore: selected.length < available.length, + protectedSequence: selected.length === 0 ? null : history ? 0 : through >= 5 ? 5 : 2, }); }, async close() {}, diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index d7c8568f3c..862ca3b9cf 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -444,10 +444,11 @@ export class DesktopTranscriptReplica { maxBytes, }); if (!this.#isNavigationCurrent(token)) return; - // A durable sequence is an event ordinal times its stride, so the oldest row - // of a Session is at no fixed number and `sequence > 0` cannot answer this. - // Ask for one row older than the anchor instead; a jump is user-initiated, - // so the extra bounded read is paid once per jump. + // A navigation target is a reading position, not a range boundary. Keep a + // bounded page on its older side too, so the first upward gesture after a + // prompt-rail jump moves through resident Turns instead of racing a prepend. + // A durable sequence is an event ordinal times its stride, so only an older + // page can also say whether this target is the start of the Session. const older = loadTail ? null : await this.#handle.loadTranscriptPage({ @@ -456,8 +457,20 @@ export class DesktopTranscriptReplica { throughSequence, cursor: null, anchorSequence: sequence, - maxBytes: 1, + maxBytes, }); + let decodedOlder: Awaited> + | undefined; + if (older) { + await this.#withDecodedPage(older, (decoded) => { + this.#acceptRange(decoded.messages); + const lastOlder = decoded.messages.at(-1); + if (lastOlder && !this.#matchesCoverageStep(sequence, lastOlder.identity + 1)) { + throw correlationError('Desktop transcript older range crossed its anchor'); + } + decodedOlder = decoded; + }); + } await this.#withDecodedPage(page, (decoded) => { if (!this.#isNavigationCurrent(token)) return; // `#resident` can flip to false across the `await` above (a concurrent @@ -467,7 +480,10 @@ export class DesktopTranscriptReplica { // memory budget. The paged catch-up guards its own post-await callback // the same way; mirror it before mutating or publishing. if (!this.#resident) return; - this.#acceptRange(decoded.messages); + const messages = decodedOlder + ? [...decodedOlder.messages, ...decoded.messages] + : decoded.messages; + this.#acceptRange(messages); if ( decoded.messages.length > 0 && (loadTail @@ -478,13 +494,13 @@ export class DesktopTranscriptReplica { } const evictedDurableSequences = [...this.#durable.keys()]; this.#clearDurable(); - const completedOverlayMessageIds = this.#installDurable(decoded.messages); + const completedOverlayMessageIds = this.#installDurable(messages); this.#durableThrough = throughSequence; this.#readingAnchorSequence = this.#intent === 'history' ? sequence : undefined; this.#readingAnchorTurnId = this.#intent === 'history' ? this.#durable.get(sequence)?.message.turnId : undefined; this.#adjacentReadingSequence = undefined; - this.#hasOlder = loadTail ? decoded.nextCursor !== null : older!.fragments.length > 0; + this.#hasOlder = loadTail ? decoded.nextCursor !== null : decodedOlder!.nextCursor !== null; this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; evictedDurableSequences.push( ...this.#evictToBudget( @@ -493,7 +509,7 @@ export class DesktopTranscriptReplica { loadTail ? (page.protectedTurnSequence ?? sequence) : sequence, ), ); - this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + this.#publish(messages, completedOverlayMessageIds, evictedDurableSequences); }); if (this.#isNavigationCurrent(token) && this.#needsOverlaySettlement(throughSequence)) { await this.#settleOverlayThrough(throughSequence, token); diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index aa84d5cccd..5de5a0a781 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -379,6 +379,8 @@ padding: var(--space-0-5) var(--space-1-5); } .maka-transcript-gap-row { + /* A range boundary is transient UI, not a reader-visible transcript anchor. */ + overflow-anchor: none; width: min(var(--maka-reading-measure), 100%); margin: var(--space-2) auto; padding-block: var(--space-1);