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