diff --git a/ui/src/lib/acp-transcript.ts b/ui/src/lib/acp-transcript.ts index 3dd3a98..cc42e04 100644 --- a/ui/src/lib/acp-transcript.ts +++ b/ui/src/lib/acp-transcript.ts @@ -302,13 +302,21 @@ export function applySessionUpdate( transcript.thinkingStartTime = 0; transcript.thinkingElapsedSeconds = 0; - // ACP is the source of truth for durable chat transcript messages. The - // chat page should not append a separate local user bubble for the same - // prompt; it should wait for this echoed user_message_chunk instead. if (historical) { appendHistoricalText(transcript, 'user', text, (update.historyMessageId || update.messageId) as string); } else { - appendStreamingText(transcript, 'user', text); + // Check for an optimistic user message to upgrade instead of duplicating. + const optimisticIdx = findLiveReplayCandidateIndex(transcript, 'user', text); + if (optimisticIdx !== -1) { + const optimistic = transcript.messages[optimisticIdx]; + const messageKey = String((update.historyMessageId || update.messageId) || '').trim(); + if (messageKey) optimistic._historyMessageId = messageKey; + const textBlock = optimistic.blocks.find((b) => b.type === 'text'); + if (textBlock) textBlock.text = text; + optimistic.streaming = false; + } else { + appendStreamingText(transcript, 'user', text); + } } return null; } diff --git a/ui/src/lib/use-chat-connection.ts b/ui/src/lib/use-chat-connection.ts index 7b5d9a2..8be2120 100644 --- a/ui/src/lib/use-chat-connection.ts +++ b/ui/src/lib/use-chat-connection.ts @@ -31,6 +31,7 @@ interface UseChatConnectionResult { status: string; permissionQueue: PermissionEntry[]; sendPrompt: (text: string) => Promise; + appendOptimisticMessage: (text: string) => void; cancelPrompt: () => void; shiftPermissionQueue: () => void; } @@ -94,6 +95,16 @@ export function useChatConnection({ setStatus('Completed'); }, []); + const appendOptimisticMessage = useCallback((text: string) => { + const session = transcriptSessionRef.current; + session.transcript.messages.push({ + role: 'user', + blocks: [{ type: 'text', text }], + streaming: false, + }); + syncTranscript(session.transcript); + }, [syncTranscript]); + const cancelPrompt = useCallback(() => { clientRef.current?.cancelPrompt(); }, []); @@ -413,6 +424,7 @@ export function useChatConnection({ status, permissionQueue, sendPrompt, + appendOptimisticMessage, cancelPrompt, shiftPermissionQueue, }; diff --git a/ui/src/pages/chat.tsx b/ui/src/pages/chat.tsx index dfc8793..139b727 100644 --- a/ui/src/pages/chat.tsx +++ b/ui/src/pages/chat.tsx @@ -264,6 +264,7 @@ export function ChatPage() { status, permissionQueue, sendPrompt, + appendOptimisticMessage, cancelPrompt, shiftPermissionQueue, } = useChatConnection({ @@ -320,9 +321,18 @@ export function ChatPage() { ? '' : buildFallbackConversationTitle(text); - // ACP owns durable transcript entries, including the echoed user prompt. - // Keep send feedback in ephemeral UI state and wait for ACP to write the - // real message so the transcript cannot diverge or duplicate. + // Show the user bubble immediately (optimistic). The server echo + // will be deduplicated via findLiveReplayCandidateIndex in acp-transcript. + appendOptimisticMessage(text); + + // Clear composer and draft immediately so the input feels responsive. + if (activeConversationId && activeSpritzName) { + clearChatDraft(activeSpritzName, activeConversationId); + if (selectedConversationRef.current?.metadata?.name === activeConversationId) { + setComposerText(''); + } + } + try { await sendPrompt(text); if (activeConversationId && fallbackTitle) { @@ -333,12 +343,6 @@ export function ChatPage() { body: JSON.stringify({ title: fallbackTitle }), }).catch(() => {}); } - if (activeConversationId && activeSpritzName) { - clearChatDraft(activeSpritzName, activeConversationId); - if (selectedConversationRef.current?.metadata?.name === activeConversationId) { - setComposerText(''); - } - } } catch (err) { if (activeConversationId && activeSpritzName) { writeChatDraft(activeSpritzName, activeConversationId, previousComposerText); @@ -349,7 +353,7 @@ export function ChatPage() { toast.error(err instanceof Error ? err.message : 'Failed to send message.'); } }, - [applyConversationTitle, composerText, name, sendPrompt], + [appendOptimisticMessage, applyConversationTitle, composerText, name, sendPrompt], ); const handleSelectConversation = useCallback((conv: ConversationInfo) => {