From 0eee5a73b37cd69d8f62d7a820d12cd211ea7b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 12 Jun 2026 23:00:31 +0800 Subject: [PATCH] fix(desktop): dedupe replayed active prompt (#755) Treat user_message_replay as idempotent for the current turn, even after thinking or tool events have already appeared in the live message list. This keeps normal sent prompts and guided queued prompts from rendering a duplicate user bubble when the CLI replays the same prompt. Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts --run Tested: cd desktop && bun run test -- src/components/chat/ChatInput.test.tsx -t "queues prompts submitted while a turn is running until the user guides them" --run Tested: bun run check:desktop Confidence: high Scope-risk: narrow --- desktop/src/stores/chatStore.test.ts | 36 ++++++++++++++++++++++++++++ desktop/src/stores/chatStore.ts | 30 ++++++++--------------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 7694708e..b7d817e5 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -3486,6 +3486,42 @@ describe('chatStore history mapping', () => { vi.useRealTimers() }) + it('does not duplicate the current prompt when CLI replays it after thinking starts', () => { + const prompt = '# 角色与目标\n构建一个协同编辑器' + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [ + { + id: 'live-user', + type: 'user_text', + content: prompt, + timestamp: 1, + }, + ], + chatState: 'thinking', + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'thinking', + text: 'I need to plan the implementation.', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'user_message_replay', + content: prompt, + }) + + const userMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages + .filter((message) => message.type === 'user_text') + expect(userMessages).toHaveLength(1) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { type: 'user_text', content: prompt }, + { type: 'thinking', content: 'I need to plan the implementation.' }, + ]) + }) + it('flushes pending text before appending an error message', () => { vi.useFakeTimers() diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 321c07a6..143b0227 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -2755,24 +2755,17 @@ function appendReplayedUserMessage( if (!displayContent) return messages const modelContent = parsed.modelContent ?? content.trim() - const optimisticIndex = findOptimisticQueuedUserMessageIndex(messages, modelContent) - if (optimisticIndex >= 0) { - const optimisticMessage = messages[optimisticIndex] - if (optimisticMessage?.type === 'user_text') { + const currentTurnUserIndex = findCurrentTurnUserMessageIndex(messages, modelContent) + if (currentTurnUserIndex >= 0) { + const optimisticMessage = messages[currentTurnUserIndex] + if (optimisticMessage?.type === 'user_text' && optimisticMessage.optimisticQueued) { const { optimisticQueued: _optimisticQueued, ...confirmedMessage } = optimisticMessage return [ - ...messages.slice(0, optimisticIndex), + ...messages.slice(0, currentTurnUserIndex), confirmedMessage, - ...messages.slice(optimisticIndex + 1), + ...messages.slice(currentTurnUserIndex + 1), ] } - } - - const last = messages[messages.length - 1] - if ( - last?.type === 'user_text' && - (last.modelContent ?? last.content).trim() === modelContent - ) { return messages } @@ -2829,19 +2822,16 @@ function mapQueuedDisplayAttachments(attachments?: AttachmentRef[]): UIAttachmen })) } -function findOptimisticQueuedUserMessageIndex( +function findCurrentTurnUserMessageIndex( messages: UIMessage[], modelContent: string, ): number { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index] - if ( - message?.type === 'user_text' && - message.optimisticQueued && - (message.modelContent ?? message.content).trim() === modelContent - ) { - return index + if (message?.type !== 'user_text') { + continue } + return (message.modelContent ?? message.content).trim() === modelContent ? index : -1 } return -1 }