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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 23:00:31 +08:00
parent f9575b9749
commit 0eee5a73b3
2 changed files with 46 additions and 20 deletions

View File

@ -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()

View File

@ -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
}