From 3060beea07f9bf991cacd6e8d1d1eb02fb4e1931 Mon Sep 17 00:00:00 2001 From: Paulo Nascimento Date: Fri, 31 Jul 2026 17:59:35 -0400 Subject: [PATCH] fix(desktop): dedupe optimistic user turns for all wire references, not only images --- .../hooks/use-session-actions/utils.test.ts | 41 +++++++++++++++++++ .../hooks/use-session-actions/utils.ts | 14 +++++-- .../assistant-ui/reference-kinds.ts | 17 ++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index ea40e7705a..335bad8113 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -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'), diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 1f86fd7c40..1a0d3ec79d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -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) diff --git a/apps/desktop/src/components/assistant-ui/reference-kinds.ts b/apps/desktop/src/components/assistant-ui/reference-kinds.ts index 453d05411d..ff7d5913b8 100644 --- a/apps/desktop/src/components/assistant-ui/reference-kinds.ts +++ b/apps/desktop/src/components/assistant-ui/reference-kinds.ts @@ -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() +}