mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user