diff --git a/.changeset/web-snapshot-sync-guard.md b/.changeset/web-snapshot-sync-guard.md new file mode 100644 index 000000000..16a6a0b3a --- /dev/null +++ b/.changeset/web-snapshot-sync-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix duplicate session snapshot reloads in the bundled web UI during resync. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 9f519dc65..11b6b6f6a 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -7,6 +7,7 @@ import { i18n } from '../i18n'; import { getKimiWebApi } from '../api'; import { isDaemonApiError, isDaemonNetworkError } from '../api/errors'; import { reconcileWorkspaceOrder, sortByWorkspaceOrder } from '../lib/workspaceOrder'; +import { createCoalescedAsyncRunner } from '../lib/snapshotSync'; import { loadUnread, loadWorkspaceOrder, @@ -747,7 +748,7 @@ function connectEventsIfNeeded(): void { // returns the authoritative {asOfSeq, epoch} and re-subscribes. if (epoch !== undefined) epochBySession[sessionId] = epoch; void currentSeq; - void syncSessionFromSnapshot(sessionId); + snapshotSyncRunner.request(sessionId); }, onError(_code: number, msg: string, _fatal: boolean) { @@ -1009,6 +1010,8 @@ async function syncSessionFromSnapshot(sessionId: string): Promise { + run(key: string): Promise; + request(key: string): void; +} + +export function createCoalescedAsyncRunner( + fn: (key: string) => Promise, +): CoalescedAsyncRunner { + const inFlight = new Map>(); + const queued = new Set(); + + function run(key: string): Promise { + const existing = inFlight.get(key); + if (existing !== undefined) return existing; + + const promise = (async () => fn(key))().finally(() => { + inFlight.delete(key); + if (queued.delete(key)) { + void run(key); + } + }); + inFlight.set(key, promise); + return promise; + } + + function request(key: string): void { + if (inFlight.has(key)) { + queued.add(key); + return; + } + void run(key); + } + + return { run, request }; +} diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 02a2392b8..e182d164a 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -5,6 +5,7 @@ import { parseFilePathLinkCandidate, } from '../src/lib/filePathLinks'; import { parseDiff } from '../src/lib/parseDiff'; +import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; describe('parseDiff', () => { @@ -76,3 +77,51 @@ describe('toolMeta', () => { ).toBe('example.com/path'); }); }); + +describe('createCoalescedAsyncRunner', () => { + it('reuses the in-flight promise for the same key', async () => { + let runs = 0; + let resolveRun!: () => void; + const runner = createCoalescedAsyncRunner(async (_key: string) => { + runs += 1; + await new Promise((resolve) => { + resolveRun = resolve; + }); + return runs; + }); + + const first = runner.run('session-a'); + const second = runner.run('session-a'); + + expect(runs).toBe(1); + resolveRun(); + await expect(Promise.all([first, second])).resolves.toEqual([1, 1]); + expect(runs).toBe(1); + }); + + it('queues at most one rerun requested while a run is in flight', async () => { + let runs = 0; + const resolvers: Array<() => void> = []; + const runner = createCoalescedAsyncRunner(async (_key: string) => { + runs += 1; + await new Promise((resolve) => { + resolvers.push(resolve); + }); + return runs; + }); + + const first = runner.run('session-a'); + runner.request('session-a'); + runner.request('session-a'); + expect(runs).toBe(1); + + resolvers[0]!(); + await first; + await Promise.resolve(); + + expect(runs).toBe(2); + resolvers[1]!(); + await Promise.resolve(); + expect(runs).toBe(2); + }); +});