From 5c6b56a866453ddeb59bbc02a0e68bbdef6c785b Mon Sep 17 00:00:00 2001 From: Xipong Date: Wed, 23 Sep 2026 17:12:13 +0300 Subject: [PATCH] fix(desktop): keep offset-drifted backfill rows in order (#119606) Co-authored-by: Xipong <217837358+Xipong@users.noreply.github.com> --- .../src/app/chat/transcript-backfill.test.ts | 59 +++++++++++++++ .../src/app/chat/transcript-backfill.ts | 74 +++++++++++++++---- 2 files changed, 118 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/chat/transcript-backfill.test.ts b/apps/desktop/src/app/chat/transcript-backfill.test.ts index c8d327a58a..9d19c711c8 100644 --- a/apps/desktop/src/app/chat/transcript-backfill.test.ts +++ b/apps/desktop/src/app/chat/transcript-backfill.test.ts @@ -122,6 +122,19 @@ describe('mergeOlderTranscriptPage', () => { expect(mergeOlderTranscriptPage(existing, older).map(m => m.rowId)).toEqual([1, 2, 3]) }) + it('keeps newer rows after an old tail when a drifting offset returns both overlap and subsequent turns', () => { + // A page initially ending at row 6 was cached. New turns persisted before + // the older-page request, so its offset now lands across rows 4–8. + const existing = [chat('one', 1), chat('two', 2), chat('three', 3), chat('four', 4), chat('five', 5), chat('six', 6)] + const fetched = [chat('four-refetched', 4), chat('five-refetched', 5), chat('six-refetched', 6), chat('seven', 7), chat('eight', 8)] + + const merged = mergeOlderTranscriptPage(existing, fetched) + + expect(merged.map(message => message.rowId)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + expect(merged.slice(0, 6)).toEqual(existing) + expect(mergeOlderTranscriptPage(merged, fetched)).toBe(merged) + }) + it('keeps reference identity when every older row is already present', () => { const existing = [chat('a', 1), chat('b', 2)] const older = [chat('a', 1)] @@ -201,6 +214,52 @@ describe('backfillOlderTranscriptPage', () => { expect(transcriptBackfillAvailable('stored-1')).toBe(false) }) + it('keeps the live tail in order when the fetched offset page includes subsequently persisted rows', async () => { + recordTranscriptTail('stored-1', { + messages: [row(4, 'four'), row(5, 'five'), row(6, 'six')], + pagination: { limit: 3, offset: 0, order: 'latest', returned: 3 } + }) + // Four rows persisted since hydration. Offset 3 now selects rows 5–7, + // rather than a page wholly before the cached 4–6 tail. + vi.mocked(getOlderSessionMessages).mockResolvedValue({ + messages: [row(5, 'five'), row(6, 'six'), row(7, 'seven')], + pagination: { limit: 3, offset: 3, order: 'latest', returned: 3 }, + session_id: 'stored-1' + } as never) + + let visible = [chat('four', 4), chat('five', 5), chat('six', 6)] + + const applied = await backfillOlderTranscriptPage({ + storedSessionId: 'stored-1', + isCurrent: () => true, + applyOlderPage: page => { + visible = mergeOlderTranscriptPage(visible, page) + } + }) + + expect(applied).toBe(true) + expect(getOlderSessionMessages).toHaveBeenCalledWith('stored-1', undefined, 3) + expect(visible.map(message => message.rowId)).toEqual([4, 5, 6, 7]) + expect(transcriptTailState('stored-1')?.nextOffset).toBe(6) + + vi.mocked(getOlderSessionMessages).mockResolvedValue({ + messages: [row(2, 'two'), row(3, 'three'), row(4, 'four')], + pagination: { limit: 3, offset: 6, order: 'latest', returned: 3 }, + session_id: 'stored-1' + } as never) + + await backfillOlderTranscriptPage({ + storedSessionId: 'stored-1', + isCurrent: () => true, + applyOlderPage: page => { + visible = mergeOlderTranscriptPage(visible, page) + } + }) + + expect(getOlderSessionMessages).toHaveBeenLastCalledWith('stored-1', undefined, 6) + expect(visible.map(message => message.rowId)).toEqual([2, 3, 4, 5, 6, 7]) + }) + it('backfills the matching connection when two owners share one session id', async () => { const sourceA = { connectionId: 'source-a', profile: 'backend-a' } const sourceB = { connectionId: 'source-b', profile: 'backend-b' } diff --git a/apps/desktop/src/app/chat/transcript-backfill.ts b/apps/desktop/src/app/chat/transcript-backfill.ts index ca354ce320..d8afe6bf20 100644 --- a/apps/desktop/src/app/chat/transcript-backfill.ts +++ b/apps/desktop/src/app/chat/transcript-backfill.ts @@ -5,12 +5,12 @@ * session. "Show earlier" first pages the DOM budget, then the in-memory store * window — and when the whole in-memory transcript is materialized but the * REST hydration was truncated (`transcript-tail` bookkeeping), this module - * fetches the next older page and prepends it to the session store. + * fetches the next older page and merges it into the session store. * * Offsets follow the backend's `order: 'latest'` semantics: measured back * from the NEWEST persisted row. Rows persisted after hydration shift that - * origin, so a fetched page can overlap rows we already hold — the prepend - * dedupes by durable row id (falling back to the rendered message id) and + * origin, so a fetched page can overlap rows we already hold and even extend + * past the cached tail. Shared durable rows anchor the merge on either side; * the offset still advances by the fetched count, which self-corrects the * drift on the next page. */ @@ -28,8 +28,10 @@ export function transcriptBackfillAvailable( } /** - * Prepend an older page onto the in-memory transcript, deduplicating rows the - * store already holds (offset drift makes overlap normal — see module doc). + * Merge a fetched page into the in-memory transcript, deduplicating rows + * the store already holds (offset drift makes overlap normal — see module doc). + * A page with no shared row is presumed older; overlapping pages use their + * shared rows to place fresh messages before, within, or after the cached tail. * Preserves reference identity when nothing changes: handing React a fresh * array of the same messages re-renders the runtime for nothing. */ @@ -41,26 +43,68 @@ export function mergeOlderTranscriptPage(existing: ChatMessage[], olderPage: Cha return existing } - const existingRowIds = new Set() - const existingIds = new Set() + const existingRowIndices = new Map() + const existingIdIndices = new Map() - for (const message of existing) { + existing.forEach((message, index) => { if (message.rowId !== undefined) { - existingRowIds.add(message.rowId) + existingRowIndices.set(message.rowId, index) } - existingIds.add(message.id) + existingIdIndices.set(message.id, index) + }) + + // The offset counts backwards from the newest durable row. While a long + // turn persists, an "older" page can overlap the cached tail AND extend + // beyond its end. Position fresh rows by the shared anchors, not by the + // page's requested direction. + const insertions = new Map() + let pending: ChatMessage[] = [] + let lastAnchor = -1 + + for (const message of olderPage) { + const anchor = + (message.rowId !== undefined ? existingRowIndices.get(message.rowId) : undefined) ?? + existingIdIndices.get(message.id) + + if (anchor === undefined) { + pending.push(message) + + continue + } + + if (pending.length) { + insertions.set(anchor, [...(insertions.get(anchor) ?? []), ...pending]) + pending = [] + } + + lastAnchor = anchor } - const fresh = olderPage.filter( - message => !(message.rowId !== undefined && existingRowIds.has(message.rowId)) && !existingIds.has(message.id) - ) + if (pending.length) { + const position = lastAnchor < 0 ? 0 : lastAnchor + 1 + insertions.set(position, [...(insertions.get(position) ?? []), ...pending]) + } - if (fresh.length === 0) { + if (insertions.size === 0) { return existing } - return [...fresh, ...existing] + const merged: ChatMessage[] = [] + + for (let index = 0; index <= existing.length; index++) { + const additions = insertions.get(index) + + if (additions) { + merged.push(...additions) + } + + if (index < existing.length) { + merged.push(existing[index]) + } + } + + return merged } /**