Files
OpenHands/__tests__/stores
Robert Brennanandopenhands 3e57aa6a04 feat(chat): queue pending user messages with sending/error/retry states (#281)
* feat(chat): queue pending user messages with sending/error/retry states

Replace the single optimisticUserMessage slot with a FIFO queue of pending
user messages so the user gets immediate feedback when they hit send and
multiple in-flight messages do not clobber each other.

- Reshape optimistic-user-message-store around enqueuePendingMessage /
  consumeOldestSendingMessage / markPendingMessageError /
  markPendingMessageSending with a 'sending' | 'error' status per entry.
- Render queued messages via a new PendingUserMessages component using
  ChatMessage's new pendingStatus + onRetry props, applying a faded
  treatment for 'sending' and an error banner + retry link for 'error'.
- Wire the WebSocket context to consume the oldest 'sending' entry when
  the server echoes a UserMessageEvent back, so the queue drains FIFO.
- Update ChatInterface, GitControlBar, TaskCard and useHandleBuildPlanClick
  to enqueue pending messages, and flip the matching entry to 'error'
  when the send call rejects.
- Add i18n strings for Sending / Send failed / Retry.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(chat): scope pending message queue per conversation and stop double 'Sending' render

Two follow-up fixes on top of the pending-message-queue refactor:

1. **Per-conversation scoping.** The optimistic queue is global but each entry
   is now tagged with the `conversationId` it was enqueued from, and
   `PendingUserMessages` filters to entries matching the active conversation
   via `useOptionalConversationId`. This means switching conversations no
   longer carries 'Sending…' bubbles over, and the WebSocket
   `UserMessageEvent` ack for one conversation can never pop a pending entry
   belonging to another. `consumeOldestSendingMessage` now takes the
   conversation id and only matches within it.

   Call sites updated: `chat-interface.tsx`, `git-control-bar.tsx`,
   `use-handle-build-plan-click.ts`. `task-card.tsx` moves the enqueue into
   the `createConversation` `onSuccess` callback so the message is tagged
   with the newly-created conversation id.

2. **Duplicate 'Sending…' bubble.** `<PendingUserMessages />` was rendered
   from two places after the previous rebase: once at the bottom of
   `<Messages>` (where the pending queue would never re-render anyway
   because `Messages` is `React.memo`'d on event ids) and once
   unconditionally from `<ChatInterface>`. Removed the render inside
   `<Messages>` so only the ChatInterface render remains.

   Also fixed a separate double-submit path in `custom-chat-input.tsx`: the
   `submittedMessage` effect listed `onSubmit` in its deps, but
   `onSubmit` (= `handleSendMessage`) is a fresh function on every parent
   render, and the parent re-renders synchronously when the new
   `enqueuePendingMessage` call mutates the store. That made the effect
   fire twice for the same `submittedMessage` value before
   `setSubmittedMessage(null)` flushed. Pinned `onSubmit` behind a ref and
   dropped it from the dep array.

Tests updated to pass `conversationId` and a new test asserts the queue is
not consumed across conversations.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(chat): address review feedback on pending-message queue

- plan-preview.test.tsx: include `useOptionalConversationId` in the
  `#/hooks/use-conversation-id` mock so the test passes now that
  `useHandleBuildPlanClick` depends on it. This was failing all 21
  PlanPreview tests in CI on the previous push.

- optimistic-user-message-store: replace the module-level counter
  (which never reset between tests) with a `Date.now()` +
  base36 random suffix. Same uniqueness guarantee, no shared state.

- optimistic-user-message-store: collapse `consumeOldestSendingMessage`
  into a single atomic `set()` so the find and the filter can't
  observe an interleaved update from another action.

- git-control-bar: capture the `pendingId` from the
  `enqueuePendingMessage` return value and, if the `send` promise
  rejects, mark that entry as error so the user gets a retry link
  instead of a stuck 'Sending…' bubble.

- chat-message: add `role="status" aria-live="polite"` to the
  'Sending…' label and `role="alert"` to the error label so screen
  readers announce send-state transitions.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(chat): content-keyed echo matching + 60s watchdog timeout for pending messages

Addresses the bot's reposted 'critical' review threads on PR #281.

**Out-of-order echo matching.**
- `PendingUserMessage` now stores both `text` (user-visible bubble) and
  `content` (the exact string sent to the server, which may include the
  appended 'Files uploaded: …' prompt). `enqueuePendingMessage` accepts
  an optional `content` and defaults it to `text` for call sites that
  don't transform the prompt.
- New `consumeMatchingPendingMessage(conversationId, content)`. It does
  an exact content match first — so an echo of 'second' arriving before
  an echo of 'hello' correctly pops 'second' instead of the oldest
  entry — and falls back to the oldest "sending" entry in the same
  conversation if no exact match exists (lets us still drain the queue
  if the server slightly munges the body).
- `conversation-websocket-context` extracts the echoed text by joining
  the `TextContent` parts of `event.llm_message.content` and passes it
  to the new matcher. Both consumption sites (main WS + planning agent
  WS) use the matcher with the main `conversationId`, so a planning
  sub-agent echo can never consume a main-conversation pending entry.
- `chat-interface` passes `content: prompt` (text + file annotations)
  to `enqueuePendingMessage`.

**60-second watchdog timeout.**
- `enqueuePendingMessage` now schedules a `setTimeout` for
  `PENDING_MESSAGE_TIMEOUT_MS` (60s, exported). If the entry is still in
  'sending' state when the timer fires, it's flipped to 'error' with
  message 'Send timed out' so the user gets a retry link instead of a
  permanently-stuck bubble. Timeout is a no-op if the echo already
  consumed the message or if it was already marked error explicitly
  (the original `errorMessage` is preserved).
- 60s is long enough to cover legitimately slow uploads / agent-server
  latency but short enough to actually rescue stuck bubbles.

**Tests.**
- `optimistic-user-message-store.test.ts` adds coverage for: storing
  separate `text`/`content`, exact-match preference, FIFO fallback,
  skipping entries already in 'error', cross-conversation isolation
  with identical content, watchdog timeout firing, and the two no-op
  cases (echo already consumed, message already failed).

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 16:49:48 -07:00
..