From a5c26bc0b7f584253817ee7662044c33e575df3e Mon Sep 17 00:00:00 2001 From: MicroGrey Date: Thu, 10 Sep 2026 17:27:36 +0800 Subject: [PATCH 1/3] fix(desktop): stabilize prompt rail history scrolling Load a bounded history window on both sides of a prompt-rail target and prevent transient gap rows from becoming scroll anchors. Generated-by: Codex --- .../e2e/transcript-scroll-cost.spec.ts | 60 +++++++++++++++++++ .../desktop-transcript-range-store.test.ts | 30 ++++++---- .../src/main/desktop-transcript-replica.ts | 34 ++++++++--- .../src/renderer/styles/chat-message.css | 2 + 4 files changed, 107 insertions(+), 19 deletions(-) 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/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); From 8179e681892f2cf52b8b9f91cad3687f6a60e73a Mon Sep 17 00:00:00 2001 From: MicroGrey Date: Thu, 10 Sep 2026 17:30:12 +0800 Subject: [PATCH 2/3] chore(desktop): register prompt rail e2e coverage Record the additional real-window transcript test and the Electron boundary it exercises. Generated-by: Codex --- apps/desktop/e2e-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From cc8ae22c8ba5f02455d19057799eb3fc66519b70 Mon Sep 17 00:00:00 2001 From: MicroGrey Date: Fri, 11 Sep 2026 15:19:58 +0800 Subject: [PATCH 3/3] test(desktop): align navigation fixtures with anchored history reads Return an empty page before the first durable record and honor exclusive anchors when selecting oversized history pages. Assert the expected read requests while preserving the ACK and selected-Turn regression checks. Generated-by: OpenAI Codex --- .../transcript-navigation-race.test.ts | 30 +++++++++++++++---- .../transcript-navigation-regression.test.ts | 25 ++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) 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() {},