From d8fe3cdbed4570f404ecf47bc5b6a0acd38de433 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:59:44 +0530 Subject: [PATCH] feat(ui): show user messages instantly on send Display user chat bubbles immediately when the send button is clicked instead of waiting for the server echo. The existing deduplication infrastructure (findLiveReplayCandidateIndex) upgrades the optimistic message with the server-assigned key when the echo arrives, preventing duplicates. --- ui/src/lib/acp-transcript.ts | 16 ++++++++++++---- ui/src/lib/use-chat-connection.ts | 12 ++++++++++++ ui/src/pages/chat.tsx | 24 ++++++++++++++---------- 3 files changed, 38 insertions(+), 14 deletions(-) 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) => {