Skip to content

Commit 2ea89e1

Browse files
authored
fix(site/src/pages/AgentsPage): show Thinking indicator immediately after sending a message (#23904)
After sending a message, `handleSend` clears stream state and inserts the user message but did not set `chatStatus` to `"running"`. Combined with #23805 narrowing `selectIsAwaitingFirstStreamChunk` to only match `chatStatus === "running"` (instead of `isActiveChatStatus` which included `"pending"`), the "Thinking..." indicator could not appear until the WebSocket delivered `status:running` — a 50–500ms+ gap. Optimistically set `chatStatus` to `"running"` in both the send and edit paths after the POST returns (non-queued). The WebSocket `status:running` event no-ops via the `setChatStatus` guard; error/pending events override the optimistic value. <details><summary>Investigation & decision log</summary> ### Root cause chain 1. **PR #23805** (`953c3bdc0`) changed `selectIsAwaitingFirstStreamChunk` from `isActiveChatStatus(state.chatStatus)` → `state.chatStatus === "running"`. Valid fix: during `"pending"`, `shouldApplyMessagePart()` drops stream parts, so `streamState` stays null and the 15s "startup taking too long" warning fired spuriously during multi-turn tool-call cycles. 2. **PR #23884** (`4b5265695`) fixed event ordering within a WebSocket batch so both `[message_part, status:running]` and `[status:running, message_part]` orderings show "Thinking...". Correct fix, but only operates **after** `chatStatus` reaches `"running"`. 3. `handleSend` never set `chatStatus` optimistically — it relied entirely on the WebSocket `status:running` event. After #23805 narrowed the selector, the gap between POST completion and WebSocket event became visible. ### Why this fix is safe - Non-queued POST = server accepted the message → `"running"` is the correct next state. - `setChatStatus("running")` guard: `if (state.chatStatus === status) return` makes the subsequent WebSocket confirmation a no-op. - If the server transitions to error/pending instead, the WebSocket event overrides the optimistic value. - `shouldApplyMessagePart()` returns `true` for `"running"`, so early stream parts arriving before the WebSocket `status:running` will not be silently dropped. ### What was NOT regressed by PR #23884 PR #23884's `setTimeout(0)` deferred flush is correct. Both event orderings now produce a render cycle where `chatStatus === "running"` and `streamState === null`, allowing "Thinking..." to appear. The `setTimeout(0)` fires in a separate macrotask, giving the browser a paint opportunity. </details>
1 parent faa5db0 commit 2ea89e1

2 files changed

Lines changed: 31 additions & 0 deletions

File tree

site/src/pages/AgentsPage/AgentChatPage.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,7 @@ const AgentChatPage: FC = () => {
752752
req: request,
753753
});
754754
store.clearStreamState();
755+
store.setChatStatus("running");
755756
setPendingEditMessageId(null);
756757
} catch (error) {
757758
setPendingEditMessageId(null);
@@ -790,6 +791,15 @@ const AgentChatPage: FC = () => {
790791
// WebSocket stream.
791792
if (!response.queued) {
792793
store.clearStreamState();
794+
// Optimistically set status to "running" so the
795+
// "Thinking..." indicator appears immediately.
796+
// The server accepted the message (not queued),
797+
// so it will start processing. The WebSocket
798+
// status:running event no-ops via the
799+
// setChatStatus guard. If the server transitions
800+
// to error/pending instead, the WebSocket event
801+
// overrides this optimistic value.
802+
store.setChatStatus("running");
793803
if (response.message) {
794804
store.upsertDurableMessage(response.message);
795805
}

site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,4 +654,25 @@ describe("selectIsAwaitingFirstStreamChunk", () => {
654654
// we should not be in a "starting" state.
655655
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
656656
});
657+
658+
it("returns true after optimistic send: clearStreamState + setChatStatus('running') + upsertDurableMessage", () => {
659+
const store = createChatStore();
660+
// Simulate a completed previous turn: assistant replied,
661+
// then server transitioned to "pending".
662+
store.upsertDurableMessage(makeMessage(1, "user", "first question"));
663+
store.upsertDurableMessage(makeMessage(2, "assistant", "first answer"));
664+
store.setChatStatus("pending");
665+
666+
// Verify baseline: not awaiting during pending.
667+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
668+
669+
// Simulate handleSend after POST returns (non-queued).
670+
// This is the exact sequence from AgentChatPage.tsx.
671+
store.clearStreamState();
672+
store.setChatStatus("running");
673+
store.upsertDurableMessage(makeMessage(3, "user", "follow-up"));
674+
675+
// "Thinking..." should appear immediately.
676+
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(true);
677+
});
657678
});

0 commit comments

Comments
 (0)