Skip to content

Optimistic send UI + bounded REST timeout for bad networks - #106

Merged
grapeot merged 2 commits into
masterfrom
feat/send-optimistic-ui
Sep 4, 2026
Merged

Optimistic send UI + bounded REST timeout for bad networks#106
grapeot merged 2 commits into
masterfrom
feat/send-optimistic-ui

Conversation

@grapeot

@grapeot grapeot commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Problem

On a bad network, the Android send button appeared completely dead. Two compounding causes:

  1. No optimistic UI — unlike iOS, Android did not insert the user's message into the list until the server round-trip completed, so there was zero visual feedback while the request was in flight.
  2. Unbounded REST read timeout — the shared OkHttp client used readTimeout(0) (infinite). A half-open connection could hang for minutes with no error and no UI change.

Fix

  • Split the network client (OpenCodeRepository.kt): REST calls now use a bounded 60s read timeout (15s connect / 60s write) so a stuck request fails fast. The SSE stream keeps an infinite read timeout on its own client — it can legitimately be silent for minutes while the agent runs a long tool call, so it must not inherit the bounded REST timeout.
  • Optimistic send (MainViewModel.kt, MainViewModelSessionActions.kt): on send, immediately insert an optimistic user row carrying a deterministic msg_<uuid> id, clear the composer, and show a spinner on the send button (ChatInputBar.kt / ChatScreen.kt).
  • Reconciliation by id membership: loadMessages / loadMoreMessages merge pending optimistic rows that the server has not echoed yet, and prune ids the server has confirmed. On send failure the row is dropped and the composer text/attachments are restored so the user can retry.
  • Server id echo: the client-chosen id is passed through PromptRequest.messageID (the opencode server schema supports messageID, and the server uses it as the user message id), so reconciliation is pure id membership.

Review-driven hardening

Addressed findings from an independent code review:

  • Handle the session.error SSE event (MainViewModelSyncActions.kt): prompt_async acknowledges with 204 before the turn runs, so a failure that happens before the user message is persisted (agent missing, session deleted, crash) only surfaces through this event. It was previously dropped, leaving a permanent ghost optimistic row and silently losing the user's text. Now, for the current session, the pending optimistic rows are dropped, their text is recovered into the composer, and an error is set. parseSessionErrorReason builds a bounded display reason (mirrors the iOS handler).
  • Session-scoped failure rollback (MainViewModelSessionActions.kt): the send-failure composer restore is now guarded by session ownership, so a failure that lands after the user switched sessions no longer clobbers the other session's draft.
  • Host-switch state reset (MainViewModel.kt): resetRuntimeForHostSwitch now clears pendingOptimisticMessageIds so stale ids don't leak across a host switch.

Tests

  • Updated all sendMessage mocks/verifies to the new 6-arg signature.
  • Optimistic send: optimistic insertion, failure rollback, and success-keeps-row-until-confirmed.
  • Cross-session rollback guard (switch sessions mid-send, then fail).
  • session.error handling: drop + text recovery, and ignore for a different session.
  • Host-switch reset of pendingOptimisticMessageIds.
  • OptimisticSendTest.kt covering the pure helpers (makeServerId, buildOptimisticMessage, mergePendingOptimisticMessages, parseSessionErrorReason).
  • Full testDebugUnitTest suite is green (327 tests).

On a bad network the send button appeared dead: Android had no optimistic
message insertion (iOS does) and the shared OkHttp client used readTimeout(0),
so a half-open connection could hang for minutes with zero feedback.

- Split the network client: REST calls get a bounded 60s read timeout so a
  stuck request fails fast; the SSE stream keeps an infinite read timeout on
  its own client (it can be silent for minutes while the agent works).
- Insert an optimistic user row (deterministic msg_<uuid> id) immediately on
  send, clear the composer, and show a spinner on the send button.
- Reconcile in loadMessages/loadMoreMessages by id membership: keep pending
  rows the server has not echoed yet, prune confirmed ids. On failure, drop
  the row and restore the composer text/attachments.
- Pass the client-chosen messageID through PromptRequest so the server echoes
  the same id back (pure id-membership reconciliation).
Address review findings on the optimistic send UI:

- Handle the session.error SSE event. prompt_async acknowledges with 204
  before the turn runs, so a failure that happens before the user message is
  persisted (agent missing, session deleted, crash) only surfaces through this
  event. Previously it was dropped, leaving a permanent ghost optimistic row
  and silently losing the user's text. Now, for the current session, drop the
  pending optimistic rows, recover their text into the composer, and set an
  error. Add parseSessionErrorReason to build a bounded display reason.
- Guard the send-failure composer rollback by session ownership so a failure
  that lands after the user switched sessions no longer clobbers the other
  session's draft.
- Reset pendingOptimisticMessageIds in resetRuntimeForHostSwitch so stale ids
  do not leak across a host switch.

Adds tests for the session.error handling (drop+recover, and ignore for a
different session), the cross-session rollback guard, the host-switch reset,
and the parseSessionErrorReason edge cases.
@grapeot
grapeot merged commit 6296414 into master Sep 4, 2026
1 check passed
@grapeot
grapeot deleted the feat/send-optimistic-ui branch September 4, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant