From b0c6a6dc25733f11684b6f12803c5b972fbb6aaf Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 27 Feb 2026 17:49:53 -0500 Subject: [PATCH] fix(site): optimistic message on agent chat submit (#22422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When sending a message in the agent detail chat, the text lingered in the input textarea while the HTTP POST round-tripped to the server. Only after the server responded did the input clear and the message appear in the timeline (via WebSocket). This created a noticeable delay where the user couldn't start typing their next message. ## Solution **Optimistic input clear** (`AgentChatInput.tsx`): - Clear the textarea and editing state *immediately* on submit, before awaiting the network call. - Capture the input text beforehand so it can be restored in the `catch` block if the request fails. **Optimistic user bubble** (`AgentDetail.tsx`): - Inject a temporary `ChatMessage` (with a negative ID) into the chat store so the user's message bubble appears in the timeline instantly. - Set chat status to `pending` and clear stream state, mirroring the existing edit-message path. - On error, roll back: remove the optimistic message and restore the previous chat status. The real message arrives via the WebSocket stream and `upsertDurableMessage` replaces the optimistic entry naturally (the server message has a positive ID, so it's inserted alongside; the optimistic negative-ID message gets cleaned up when `replaceMessages` is called with the authoritative message list from the next query invalidation). ## Testing - Type a message and press Enter — input clears and bubble appears immediately. - Simulate a network error — input text is restored, optimistic bubble is removed. - Edit an existing message — unchanged behavior (already had optimistic updates). - Queue a message while streaming — unchanged behavior. --- site/src/pages/AgentsPage/AgentChatInput.tsx | 38 ++++++++++++-------- site/src/pages/AgentsPage/AgentDetail.tsx | 26 +++++++++++++- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx index b07e99f677..81b74d60ce 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -324,25 +324,35 @@ export const AgentChatInput = memo( isEditingHistoryMessage && editingHistoryMessageID !== null ? editingHistoryMessageID : undefined; + // Capture the raw input before clearing so we can restore + // it if the request fails. + const capturedInput = input; + + // Clear the input and editing state immediately so the + // user can start typing their next message without waiting + // for the network round-trip. + setInput(""); + onInputChange?.(""); + if (queueEditID !== null) { + setEditingQueuedMessageID(null); + setDraftBeforeQueueEdit(null); + } + if (isEditingHistoryMessage) { + setIsEditingHistoryMessage(false); + setEditingHistoryMessageID(null); + setDraftBeforeHistoryEdit(null); + onEditCleared?.(); + } + try { - await onSend(input, editedMessageID); + await onSend(capturedInput, editedMessageID); if (queueEditID !== null && onDeleteQueuedMessage) { await onDeleteQueuedMessage(queueEditID); } - setInput(""); - onInputChange?.(""); - if (queueEditID !== null) { - setEditingQueuedMessageID(null); - setDraftBeforeQueueEdit(null); - } - if (isEditingHistoryMessage) { - setIsEditingHistoryMessage(false); - setEditingHistoryMessageID(null); - setDraftBeforeHistoryEdit(null); - onEditCleared?.(); - } } catch { - // Keep input on failure so the user can retry. + // Restore the input so the user can retry. + setInput(capturedInput); + onInputChange?.(capturedInput); } finally { // Re-focus the textarea so the user can keep typing. textareaRef.current?.focus(); diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 116ed2ae17..3dc0bad79a 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -614,7 +614,31 @@ const AgentDetail: FC = () => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } - await sendMutation.mutateAsync(request); + + // Inject an optimistic user message so the bubble appears in + // the timeline immediately, without waiting for the server. + const previousMessages = getOrderedMessagesFromStore(store); + const previousChatStatus = store.getSnapshot().chatStatus; + const optimisticMessage: TypesGen.ChatMessage = { + id: -Date.now(), + chat_id: agentId, + created_at: new Date().toISOString(), + role: "user", + content: toOptimisticMessageParts(content), + }; + store.upsertDurableMessage(optimisticMessage); + store.clearStreamState(); + store.setChatStatus("pending"); + + try { + await sendMutation.mutateAsync(request); + } catch (error) { + // Roll back the optimistic message so the timeline + // returns to its previous state. + store.replaceMessages(previousMessages); + store.setChatStatus(previousChatStatus); + throw error; + } if (typeof window !== "undefined") { if (selectedModelConfigID) { localStorage.setItem(