fix(site): optimistic message on agent chat submit (#22422)

## 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.
This commit is contained in:
Kyle Carberry
2026-02-27 17:49:53 -05:00
committed by GitHub
parent 5fb644a6cd
commit b0c6a6dc25
2 changed files with 49 additions and 15 deletions
+24 -14
View File
@@ -324,25 +324,35 @@ export const AgentChatInput = memo<AgentChatInputProps>(
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();
+25 -1
View File
@@ -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(