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>
This commit is contained in:
Kyle Carberry
2026-04-01 12:57:18 +00:00
committed by GitHub
parent faa5db0cf0
commit 2ea89e1f1b
2 changed files with 31 additions and 0 deletions
@@ -752,6 +752,7 @@ const AgentChatPage: FC = () => {
req: request,
});
store.clearStreamState();
store.setChatStatus("running");
setPendingEditMessageId(null);
} catch (error) {
setPendingEditMessageId(null);
@@ -790,6 +791,15 @@ const AgentChatPage: FC = () => {
// WebSocket stream.
if (!response.queued) {
store.clearStreamState();
// Optimistically set status to "running" so the
// "Thinking..." indicator appears immediately.
// The server accepted the message (not queued),
// so it will start processing. The WebSocket
// status:running event no-ops via the
// setChatStatus guard. If the server transitions
// to error/pending instead, the WebSocket event
// overrides this optimistic value.
store.setChatStatus("running");
if (response.message) {
store.upsertDurableMessage(response.message);
}
@@ -654,4 +654,25 @@ describe("selectIsAwaitingFirstStreamChunk", () => {
// we should not be in a "starting" state.
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
});
it("returns true after optimistic send: clearStreamState + setChatStatus('running') + upsertDurableMessage", () => {
const store = createChatStore();
// Simulate a completed previous turn: assistant replied,
// then server transitioned to "pending".
store.upsertDurableMessage(makeMessage(1, "user", "first question"));
store.upsertDurableMessage(makeMessage(2, "assistant", "first answer"));
store.setChatStatus("pending");
// Verify baseline: not awaiting during pending.
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(false);
// Simulate handleSend after POST returns (non-queued).
// This is the exact sequence from AgentChatPage.tsx.
store.clearStreamState();
store.setChatStatus("running");
store.upsertDurableMessage(makeMessage(3, "user", "follow-up"));
// "Thinking..." should appear immediately.
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(true);
});
});