Skip to content

Closes #150#151

Merged
dzianisv merged 1 commit into
mainfrom
fix/issue-150-live-refresh
Jul 24, 2026
Merged

Closes #150#151
dzianisv merged 1 commit into
mainfrom
fix/issue-150-live-refresh

Conversation

@dzianisv

Copy link
Copy Markdown
Owner

Root cause

The session detail screen (app/session/[id].tsx) re-runs selectSession() on every navigation focus, not just on mount — that's #121's fix, which re-binds the screen to its session on every re-entry because the native stack keeps screens mounted underneath a pushed one.

The bug: selectSession() unconditionally set isLoading: true at the start of every call, including a re-focus of the session already shown on screen. That hides the entire conversation — message list and composer — behind a spinner for as long as that (redundant) GET /session/:id + GET /session/:id/message fetch takes.

Meanwhile, SSE keeps delivering message.updated / message.part.updated / session.status events the whole time — useEvents's connect() loop calls useSessions.getState().handleEvent(...), which does keep updating messages/parts in the store — but the screen can't render any of it while isLoading is blocking the message list.

If that redundant GET is slow or stalls (flaky mobile network, cold TCP handshake, etc.), the screen looks permanently stuck on "loading" — even though the conversation is progressing live server-side and the store already has (or is receiving) the up-to-date messages. Backing out to the sessions list and re-entering only "fixes" it because it's a fresh retry that happens not to stall the second time — not because anything was actually resolved. This matches the reported repro exactly: stuck loading during an open conversation, fixed only by leaving and re-entering.

Fix

src/stores/sessions.ts:

  1. Don't force the blocking spinner for a same-session re-focus. selectSession() now only sets isLoading: true for a genuinely cold load (no session shown yet, or switching to a different session) — derived via the new pure isColdSessionLoad(currentSessionID, targetSessionID). Re-selecting the session already on screen refreshes in the background without hiding the (already-live-updating) conversation.
  2. Safety net: any live event proves the screen has data to show. handleEvent() now clears isLoading unconditionally whenever a message.updated, message.part.updated, or session.updated event lands for the active session — via the new pure isLiveEventForSession(eventSessionID, activeSessionID) (also used to tighten the existing message.updated guard). This unsticks the spinner even in the case where the initial GET never resolves at all.

Both are pure functions with no React Native/zustand dependencies, in src/lib/session-load-reconcile.ts, unit-tested in src/lib/session-load-reconcile.test.ts (7 cases: cold vs. warm load derivation, and live-event session matching including missing/mismatched ids).

Doesn't touch the #121 focus-resync or #123/#124 reconnect-resync code paths — refreshPending(), resyncBusySessions(), and the useFocusEffect re-selection itself are all unchanged; only the isLoading gating inside selectSession/handleEvent changed.

Testing

  • npm test: 216/219 passing (3 pre-existing env-gated cancellations, unrelated to this change), including the 7 new session-load-reconcile.test.ts cases.
  • npx tsc --noEmit: clean.

No device test was run (none available in this environment) — reasoned through the state flow instead: reviewed every call site of selectSession/handleEvent, confirmed the only trigger for selectSession is useFocusEffect in app/session/[id].tsx, and confirmed the fix can't leave isLoading stuck (it's only ever forced true for a cold load, and always eventually gets set false on that load's own success/failure path, plus the new live-event safety net).

On-device verify steps

  1. Open a session and send a message that takes a few seconds to respond (or throttle network in dev tools / airplane-mode-toggle mid-request).
  2. While the response is streaming, background the app and foreground it again (or navigate to a modal like the model picker and dismiss it) to force a re-focus — confirm the conversation and streaming response stay visible the whole time, with no spinner flash hiding them.
  3. Open a different session (cold load) — confirm the spinner still shows briefly as before, since there's nothing to display yet.
  4. Simulate a slow/stalled history fetch (e.g. throttle the network right as you open a session that's actively responding) — confirm live SSE updates still render and the screen doesn't get stuck once at least one event arrives, even if the GET itself is still pending.

🤖 Generated with Claude Code

…ithout re-nav (closes #150)

Root cause: selectSession() re-runs on every navigation focus (#121's
resync), forcing isLoading back to true even when re-selecting the
session already shown on screen. That hides the whole conversation
(messages + composer) behind a spinner for as long as the redundant
GET takes -- while live SSE message/part updates keep flowing to the
store the entire time, just invisible behind the spinner. If that
GET is slow or stalls, the screen looks permanently "loading"; leaving
and re-entering only "fixes" it because it's a fresh retry, not
because anything was actually resolved.

Fix: only force isLoading=true for a genuinely cold load (no session
shown yet, or switching to a different one) via isColdSessionLoad().
A same-session re-focus refreshes in the background without hiding
existing (and live-updating) content. As a second safety net, any
live message.updated/message.part.updated/session.updated event for
the active session now clears isLoading unconditionally via
isLiveEventForSession() -- proof-of-life that unsticks the spinner
even if the GET itself never resolves.

Both are pure, unit-tested in src/lib/session-load-reconcile.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dzianisv
dzianisv merged commit 616753b into main Jul 24, 2026
7 of 9 checks passed
@dzianisv
dzianisv deleted the fix/issue-150-live-refresh branch July 24, 2026 16:51
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