fix(desktop): dedupe optimistic user turns for all wire references, not only images

This commit is contained in:
Paulo Nascimento
2026-07-31 17:59:35 -04:00
committed by kshitij
parent b9b0505cfc
commit 3060beea07
3 changed files with 68 additions and 4 deletions
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { WIRE_REFERENCE_KINDS } from '@/components/assistant-ui/reference-kinds'
import type { ChatMessage } from '@/lib/chat-messages'
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
import { $desktopOnboarding } from '@/store/onboarding'
@@ -575,6 +576,46 @@ describe('preserveLocalPendingTurnMessages', () => {
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
it('does not duplicate the optimistic file turn when the persisted turn carries @file refs', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'text', {
attachmentRefs: ['@file:X']
})
]
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', '@file:X\n\ntext')
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
it.each(WIRE_REFERENCE_KINDS.filter(kind => kind !== 'file' && kind !== 'image'))(
'does not duplicate the optimistic %s turn when the persisted turn carries its directive',
kind => {
const ref = `@${kind}:X`
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'text', {
attachmentRefs: [ref]
})
]
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', `${ref}\n\ntext`)
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
}
)
it('still keeps a genuinely uncommitted optimistic image turn when the persisted text differs', () => {
const previous = [
msg('1-user', 'user', 'first'),
@@ -1,7 +1,8 @@
import { getSession } from '@/hermes'
import { textWithoutReferenceLines } from '@/components/assistant-ui/reference-kinds'
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages, textWithoutImageRefs } from '@/lib/embedded-images'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
@@ -403,7 +404,8 @@ export function preserveLocalPendingTurnMessages(
if (
isOptimisticUser &&
latestAuthoritativeUser &&
textWithoutImageRefs(chatMessageText(latestAuthoritativeUser)) === textWithoutImageRefs(chatMessageText(message))
textWithoutReferenceLines(chatMessageText(latestAuthoritativeUser)) ===
textWithoutReferenceLines(chatMessageText(message))
) {
continue
}
@@ -415,7 +417,9 @@ export function preserveLocalPendingTurnMessages(
continue
}
if (textWithoutImageRefs(chatMessageText(authoritative)) === textWithoutImageRefs(chatMessageText(message))) {
if (
textWithoutReferenceLines(chatMessageText(authoritative)) === textWithoutReferenceLines(chatMessageText(message))
) {
continue
}
}
@@ -484,7 +488,9 @@ export function appendLiveSessionProjection(
}
const persistedInLatestRun = (text: string): boolean =>
latestUserRun.some(message => textWithoutImageRefs(chatMessageText(message)) === textWithoutImageRefs(text))
latestUserRun.some(
message => textWithoutReferenceLines(chatMessageText(message)) === textWithoutReferenceLines(text)
)
const inflightUserAlreadyPersisted = Boolean(inflightUser) && persistedInLatestRun(inflightUser)
@@ -157,3 +157,20 @@ const REFERENCE_PATTERN = /@(file|folder|url|image|tool|line|terminal|session):(
export function referenceRe(): RegExp {
return new RegExp(REFERENCE_PATTERN.source, 'g')
}
/** Remove reference-only lines when comparing visible message text. */
export function textWithoutReferenceLines(text: string): string {
const matcher = referenceRe()
return text
.split('\n')
.filter(line => {
const candidate = line.trimEnd()
matcher.lastIndex = 0
const match = matcher.exec(candidate)
return !match || match.index !== 0 || match[0] !== candidate
})
.join('\n')
.trim()
}