diff --git a/.changeset/fix-web-duplicate-user-message.md b/.changeset/fix-web-duplicate-user-message.md new file mode 100644 index 0000000000..615fabee18 --- /dev/null +++ b/.changeset/fix-web-duplicate-user-message.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix duplicate user message bubbles after a session snapshot resync. diff --git a/apps/kimi-web/src/lib/snapshotMessages.ts b/apps/kimi-web/src/lib/snapshotMessages.ts index 29b301ee6a..300008dc59 100644 --- a/apps/kimi-web/src/lib/snapshotMessages.ts +++ b/apps/kimi-web/src/lib/snapshotMessages.ts @@ -18,9 +18,23 @@ export function mergeSnapshotMessages( const earliestSnapshotMs = Date.parse(snapshot[0]!.createdAt); if (Number.isNaN(earliestSnapshotMs)) return snapshot; + // The optimistic bubble keeps its client-side id to avoid remounting, while + // submitPrompt stamps the authoritative v2 user-message id into promptId. + // Match that identity against the snapshot instead of guessing from content: + // repeated prompts are distinct messages even when their text/media is equal. + const snapshotIds = new Set(snapshot.map((m) => m.id)); + const snapshotUserIds = new Set(snapshot.filter((m) => m.role === 'user').map((m) => m.id)); + const older = loaded.filter((message) => { const createdAtMs = Date.parse(message.createdAt); - return !Number.isNaN(createdAtMs) && createdAtMs < earliestSnapshotMs; + if (Number.isNaN(createdAtMs) || createdAtMs >= earliestSnapshotMs) return false; + if (snapshotIds.has(message.id)) return false; + if ( + message.role === 'user' && + message.promptId !== undefined && + snapshotUserIds.has(message.promptId) + ) return false; + return true; }); return older.length > 0 ? [...older, ...snapshot] : snapshot; diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 1e7f3dc44e..7562f6cc0c 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -534,6 +534,40 @@ describe('mergeSnapshotMessages', () => { expect(mergeSnapshotMessages([], snapshot)).toBe(snapshot); expect(mergeSnapshotMessages(snapshot, [])).toEqual([]); }); + + function optimisticUser(id: string, createdAt: string, text: string, promptId: string): AppMessage { + return { + id, + sessionId: 's1', + role: 'user', + content: [{ type: 'text', text }], + createdAt, + promptId, + metadata: { 'kimiWeb.optimisticUserMessage': true }, + }; + } + + function realUser(id: string, createdAt: string, text: string): AppMessage { + return { + id, + sessionId: 's1', + role: 'user', + content: [{ type: 'text', text }], + createdAt, + }; + } + + it('drops an optimistic user message when its promptId is the snapshot message id', () => { + const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_9')]; + const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_9']); + }); + + it('keeps an optimistic user message when a different snapshot message repeats its content', () => { + const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_8')]; + const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_opt_1', 'msg_9']); + }); }); describe('mergeSnapshotSubagents', () => {