From 931e20d814d838dc25e9d27e97ad752d2d90216d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 29 Jul 2026 17:53:55 +0300 Subject: [PATCH] fix: preserve history coverage during tail refresh Keeps an existing complete cursor/coverage after refreshing the latest messages Prevents tail refreshes from reintroducing an incorrect "load older" state Adds a regression test for complete history coverage across refreshes --- .../src/sync/session-message-loader.test.ts | 22 +++++++++++++++++++ .../ui/src/sync/session-message-loader.ts | 11 ++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/sync/session-message-loader.test.ts b/packages/ui/src/sync/session-message-loader.test.ts index 769ae573..f7fcd275 100644 --- a/packages/ui/src/sync/session-message-loader.test.ts +++ b/packages/ui/src/sync/session-message-loader.test.ts @@ -89,6 +89,28 @@ describe("SessionMessageLoader", () => { childStores.disposeAll() }) + test("preserves complete history coverage across a tail refresh", async () => { + let calls = 0 + const { childStores, loader } = createLoader(async ({ sessionID }) => { + calls += 1 + return calls === 1 + ? response([createRecord(sessionID, "msg_1")]) + : response([createRecord(sessionID, "msg_2")], "stale-tail-cursor") + }) + const target = { directory: "/repo", sessionID: "session-a" } + + await loader.ensure(target) + expect(loader.getSnapshot(target).complete).toBe(true) + expect(loader.getSnapshot(target).cursor).toBe(undefined) + + await loader.refreshTail(target, 2) + + expect(loader.getSnapshot(target).complete).toBe(true) + expect(loader.getSnapshot(target).cursor).toBe(undefined) + loader.dispose() + childStores.disposeAll() + }) + test("does not deduplicate identical session IDs across directories", async () => { const calls: string[] = [] const { childStores, loader } = createLoader(async ({ directory, sessionID }) => { diff --git a/packages/ui/src/sync/session-message-loader.ts b/packages/ui/src/sync/session-message-loader.ts index be6d1ec8..540c6232 100644 --- a/packages/ui/src/sync/session-message-loader.ts +++ b/packages/ui/src/sync/session-message-loader.ts @@ -265,18 +265,25 @@ export class SessionMessageLoader { const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false }) this.bumpGeneration(entry) return this.startLoad(normalized, entry, store, "refresh", async (isCurrent) => { + const previousCoverage = entry.snapshot.resolved + ? { cursor: entry.snapshot.cursor, complete: entry.snapshot.complete } + : null const page = await this.fetchPage(normalized, Math.max(1, limit)) if (!isCurrent()) return const committed = this.commitPage(normalized, entry, store, page, "merge", isCurrent) if (!committed || !isCurrent()) return + const coverage = previousCoverage ?? page this.patchEntry(entry, { status: "ready", loadingKind: null, error: null, resolved: true, limit: Math.max(entry.snapshot.limit, committed.messages.length), - cursor: page.cursor, - complete: page.complete, + // A tail refresh uses a deliberately small window. Its cursor only + // describes that window, so it must not replace the established + // history coverage and spuriously expose "load older". + cursor: coverage.cursor, + complete: coverage.complete, updatedAt: Date.now(), }) this.persistCoverage(normalized, entry.snapshot)