fix(desktop): show guided queued prompts immediately (#755)

Insert guided queued prompts into the desktop transcript as soon as the user clicks Guide, then confirm them on CLI replay without adding duplicate user bubbles.

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"
Tested: cd desktop && bun run test -- src/components/chat/ChatInput.test.tsx
Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 22:29:24 +08:00
parent ea82c6ec70
commit b70f25e62b
3 changed files with 101 additions and 11 deletions

View File

@ -452,22 +452,27 @@ describe('ChatInput file mentions', () => {
attachments: [],
})
expect(screen.queryByTestId('pending-user-message')).not.toBeInTheDocument()
expect(useChatStore.getState().sessions[sessionId]?.messages.at(-1)).toMatchObject({
type: 'assistant_text',
content: 'working',
})
expect(useChatStore.getState().sessions[sessionId]?.messages).toMatchObject([
{ type: 'assistant_text', content: 'workingstill answering' },
{ type: 'user_text', content: 'please adjust the current direction' },
])
act(() => {
useChatStore.getState().handleServerMessage(sessionId, {
type: 'tool_result',
toolUseId: 'tool-1',
content: 'tool finished',
isError: false,
})
useChatStore.getState().handleServerMessage(sessionId, {
type: 'user_message_replay',
content: 'please adjust the current direction',
})
})
expect(useChatStore.getState().sessions[sessionId]?.messages.at(-1)).toMatchObject({
type: 'user_text',
content: 'please adjust the current direction',
})
const guidedMessages = useChatStore.getState().sessions[sessionId]?.messages
.filter((message) => message.type === 'user_text' && message.content === 'please adjust the current direction')
expect(guidedMessages).toHaveLength(1)
})
it('edits and deletes queued prompts without sending them', async () => {

View File

@ -1372,9 +1372,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const queuedMessage = (session?.queuedUserMessages ?? []).find((message) => message.id === messageId)
if (!session || !queuedMessage) return
get().removeQueuedUserMessage(sessionId, messageId)
if (session.chatState === 'idle') {
get().removeQueuedUserMessage(sessionId, messageId)
get().sendMessage(
sessionId,
queuedMessage.content,
@ -1387,6 +1386,22 @@ export const useChatStore = create<ChatStore>((set, get) => ({
return
}
const now = Date.now()
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, (currentSession) => {
const pendingText = `${currentSession.streamingText}${consumePendingDelta(sessionId)}`
const baseMessages = pendingText.trim()
? appendAssistantTextMessage(currentSession.messages, pendingText, now)
: currentSession.messages
return {
messages: appendOptimisticQueuedUserMessage(baseMessages, queuedMessage, now),
queuedUserMessages: (currentSession.queuedUserMessages ?? [])
.filter((message) => message.id !== messageId),
...(pendingText.trim() ? { streamingText: '' } : {}),
}
}),
}))
wsManager.send(sessionId, {
type: 'user_message',
content: queuedMessage.content,
@ -2740,6 +2755,19 @@ 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 { optimisticQueued: _optimisticQueued, ...confirmedMessage } = optimisticMessage
return [
...messages.slice(0, optimisticIndex),
confirmedMessage,
...messages.slice(optimisticIndex + 1),
]
}
}
const last = messages[messages.length - 1]
if (
last?.type === 'user_text' &&
@ -2761,6 +2789,63 @@ function appendReplayedUserMessage(
]
}
function appendOptimisticQueuedUserMessage(
messages: UIMessage[],
message: QueuedUserMessage,
timestamp: number,
): UIMessage[] {
const displayContent = message.displayContent.trim()
const modelContent = message.content.trim()
const attachments = mapQueuedDisplayAttachments(message.displayAttachments)
if (!displayContent && !attachments) return messages
return [
...messages,
{
id: nextId(),
type: 'user_text',
content: displayContent,
...(modelContent && modelContent !== displayContent ? { modelContent } : {}),
...(attachments ? { attachments } : {}),
timestamp,
optimisticQueued: true,
},
]
}
function mapQueuedDisplayAttachments(attachments?: AttachmentRef[]): UIAttachment[] | undefined {
if (!attachments?.length) return undefined
return attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name || attachment.path || attachment.mimeType || attachment.type,
path: attachment.path,
data: attachment.data,
mimeType: attachment.mimeType,
isDirectory: attachment.isDirectory,
lineStart: attachment.lineStart,
lineEnd: attachment.lineEnd,
note: attachment.note,
quote: attachment.quote,
}))
}
function findOptimisticQueuedUserMessageIndex(
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
}
}
return -1
}
function replaceQueuedMessageDisplayContent(
message: QueuedUserMessage,
nextDisplayContent: string,

View File

@ -265,7 +265,7 @@ export type TaskSummaryItem = {
}
export type UIMessage =
| { id: string; type: 'user_text'; content: string; modelContent?: string; transcriptMessageId?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'user_text'; content: string; modelContent?: string; transcriptMessageId?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean; optimisticQueued?: boolean }
| { id: string; type: 'assistant_text'; content: string; transcriptMessageId?: string; timestamp: number; model?: string }
| { id: string; type: 'thinking'; content: string; timestamp: number }
| {