diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index a994ecf8da7c..741423e98c13 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -114,9 +114,8 @@ export function matchingNativeChatUserTexts( } /** - * How many leading pending texts concatenate exactly to `userText`. - * Covers rapid-send glue ("joke"+"continue" → "jokecontinue") without matching - * unrelated prefixes ("hi" ↛ "history"). + * How many leading pending texts concatenate exactly to `userText`, with or + * without one normalized whitespace separator at each send boundary. */ export function countLeadingPendingTextsGluedToUserText( pendingTexts: readonly string[], @@ -125,17 +124,23 @@ export function countLeadingPendingTextsGluedToUserText( if (pendingTexts.length === 0 || userText.length === 0) { return 0 } - let combined = '' + let candidates = new Set() for (let index = 0; index < pendingTexts.length; index += 1) { const piece = pendingTexts[index] if (!piece) { return 0 } - combined += piece - if (combined === userText) { + const next = + index === 0 + ? new Set([piece]) + : new Set( + [...candidates].flatMap((candidate) => [candidate + piece, `${candidate} ${piece}`]) + ) + candidates = new Set([...next].filter((candidate) => userText.startsWith(candidate))) + if (candidates.has(userText)) { return index + 1 } - if (!userText.startsWith(combined)) { + if (candidates.size === 0) { return 0 } } 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 75b2c3d34c77..121868550bd1 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 @@ -189,6 +189,36 @@ describe('prunePendingSends', () => { ).toEqual([]) }) + it('prunes optimistic sends glued with a retained whitespace separator', () => { + const pending = [pendingOf('p1', 'tell me a joke '), pendingOf('p2', 'continue')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'tell me a joke continue'), + assistantMessage('a1', 'a joke') + ]) + ).toEqual([]) + }) + + it('matches mixed separated and separator-less rapid sends', () => { + const pending = [pendingOf('p1', 'first'), pendingOf('p2', 'second '), pendingOf('p3', 'third')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'firstsecond third'), + assistantMessage('a1', 'done') + ]) + ).toEqual([]) + }) + + it('prunes repeated prompts represented by one separated transcript turn', () => { + const pending = [pendingOf('p1', 'repeat '), pendingOf('p2', 'repeat')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'repeat repeat'), + assistantMessage('a1', 'done') + ]) + ).toEqual([]) + }) + it('does not treat an unrelated longer user turn as a glued match', () => { const pending = [pendingOf('p1', 'hi')] expect( @@ -198,6 +228,16 @@ describe('prunePendingSends', () => { ]) ).toEqual(pending) }) + + it('does not accept a separated glue candidate that is only a user-text prefix', () => { + const pending = [pendingOf('p1', 'hi'), pendingOf('p2', 'story')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'hi story continued'), + assistantMessage('a1', 'ok') + ]) + ).toEqual(pending) + }) }) describe('pendingSendsAsMessages', () => {