From bae29fd1e255b43b11b46de9140f0c23cda1ad32 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:48:55 -0700 Subject: [PATCH] fix(native-chat): handle reordering with timestampless transcripts (#10050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok transcripts carry no timestamps. Previous logic excluded these rows from matching, leaving pending sends and launch prompts unmatched and causing the seeded bubble to appear rank-pinned at the list tail — which reads as conversation reordering. Now pending sends, launch prompts, and their pruning rules treat null timestamps as matching-eligible, allowing echo suppression and proper cleanup of delivered messages. --- .../native-chat/native-chat-pending.test.ts | 32 +++++++++++++++++++ .../native-chat/native-chat-pending.ts | 11 +++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 78ffa01b7..b0cea7841 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -134,6 +134,16 @@ describe('prunePendingSends', () => { expect(prunePendingSends(pending, [oldUser, oldAnswer])).toEqual(pending) }) + it('prunes a first send against a timestampless transcript turn (grok)', () => { + const pending = [{ ...pendingOf('p1', 'rename it'), afterMessageId: null }] + const transcript = [ + { ...userMessage('u1', 'rename it'), timestamp: null }, + { ...assistantMessage('a1', 'done'), timestamp: null } + ] + + expect(prunePendingSends(pending, transcript)).toEqual([]) + }) + it('prunes only one of two identical pending sends for one completed turn', () => { const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] expect( @@ -246,6 +256,14 @@ describe('pendingSendsAsMessages', () => { expect(prunePendingSends(pending, remoteTranscript)).toEqual([]) }) + it('hides a first send while its timestampless transcript turn is visible (grok)', () => { + const pending = [{ ...pendingOf('p1', 'rename it'), afterMessageId: null }] + + expect( + pendingSendsAsMessages(pending, [{ ...userMessage('u1', 'rename it'), timestamp: null }]) + ).toEqual([]) + }) + it('hides only one of two identical pending sends for one real user turn', () => { const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] expect(pendingSendsAsMessages(pending, [userMessage('u1', 'repeat')]).map((m) => m.id)).toEqual( @@ -340,6 +358,20 @@ describe('launchPromptAsMessage', () => { ).toBe(true) }) + // Grok transcripts carry no timestamps; before the null-matchable rule the + // seeded bubble was never hidden or pruned and sat rank-pinned at the list + // tail forever, reading as the conversation reordering. + it('hides and prunes the launch prompt against a timestampless transcript (grok)', () => { + const entry = { tabId: 'tab-1', agent: 'grok' as const, text: 'rename it', createdAt: 42 } + const transcript = [ + { ...userMessage('u1', 'rename it'), timestamp: null }, + { ...assistantMessage('a1', 'done'), timestamp: null } + ] + + expect(launchPromptAsMessage(entry, transcript)).toBeNull() + expect(shouldPruneLaunchPrompt(entry, transcript)).toBe(true) + }) + it('does not bind a launch prompt to an older identical completed turn', () => { const entry = { tabId: 'tab-1', diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 2fb916c0f..466ec16cd 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -111,8 +111,11 @@ function messageIsAfterPendingTimestamp( message: NativeChatMessage, pending: NativeChatPendingSend ): boolean { + // Why: some transcripts (e.g. Grok) never carry timestamps. Excluding their + // rows would make the echo unmatchable forever, stranding a rank-pinned + // bubble at the list tail — which reads as the conversation reordering. if (message.timestamp === null) { - return false + return true } const boundary = nativeChatPendingMatchingAfter(pending) // A transcript-clock boundary describes an existing message, so exclude ties. @@ -231,9 +234,11 @@ export function launchPromptAsMessage( if (!entry) { return null } + // Why: a launch prompt seeds a brand-new session, so a matching user turn + // with no timestamp (e.g. Grok transcripts) can only be its own delivery. const represented = matchingNativeChatUserContentCounts( existingMessages.filter( - (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + (message) => message.timestamp === null || message.timestamp >= entry.createdAt ) ) if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) { @@ -256,7 +261,7 @@ export function shouldPruneLaunchPrompt( messages: NativeChatMessage[] ): boolean { const relevant = messages.filter( - (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + (message) => message.timestamp === null || message.timestamp >= entry.createdAt ) return ( (advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0