From f6daa37ff43e8486ab3f8598de488ceb61e95a82 Mon Sep 17 00:00:00 2001 From: Joshua Castaneda Date: Sat, 15 Aug 2026 01:46:40 -0700 Subject: [PATCH] fix(events): reconcile open session transcript after SSE reconnect A prompt submitted from another client (CLI, TUI, another device) while this client's stream was down never appeared in an already-open session until the user navigated away and back. resyncBusySessions() was the only reconnect-time reconciliation, and it only considers sessions this client has marked "busy". A client only learns a session is busy from an SSE `session.status` event -- so if the stream was down when the other client prompted, this client never saw that event, the session is still "idle" in its store, and resyncBusySessions() finds nothing to do and returns immediately. Reconnect resumes the stream from "now" without replaying missed events, so the messages from the gap are never fetched by anything. Add reconcileOpenSession(), invoked alongside resyncBusySessions() in the same once-per-reconnect block: refetch the currently-open session's transcript unconditionally. refreshMessages() replaces messages/parts without touching isLoading, so this is a silent background reconcile rather than a spinner over content the user is already reading -- which only holds because #150's fix stopped same-session refreshes from forcing the loading state. Distinct from #150: that fixed a spinner hiding content that was arriving. This fixes content that never arrives at all. Same symptom, different layer. Server-side contract was verified to already pass (an already-connected /global/event subscriber does receive another client's prompt immediately), so this closes the remaining client-side gap. Co-Authored-By: Claude Opus 5 --- src/stores/events.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/stores/events.ts b/src/stores/events.ts index c53a8481..adb895fb 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -145,6 +145,37 @@ async function resyncBusySessions() { ) } +// Reconcile the transcript of the session the user currently has open, after +// an SSE reconnect. +// +// resyncBusySessions() above is not enough on its own. It only considers +// sessions this client has marked "busy", and a client only learns a session +// is busy *from an SSE event*. If the stream was down while another client +// (CLI, TUI, another device) submitted a prompt, this client never saw the +// busy `session.status` event, so the session is still "idle" in its store, +// resyncBusySessions() finds nothing to do, and — because reconnect resumes +// the stream from "now" and does not replay missed events — the messages that +// arrived during the gap are never fetched. The open transcript then stays +// stale until the user navigates away and back, which is the reported +// cross-client staleness symptom. +// +// refreshMessages() re-fetches the current session and replaces messages/parts +// without touching isLoading, so this lands as a silent background reconcile +// rather than a spinner over content the user is already reading. +// +// Note this can overlap with resyncBusySessions() for a session that was busy +// and has since gone idle — both would refresh. That costs one redundant GET +// on an infrequent event, which is cheaper than the coupling needed to dedupe. +async function reconcileOpenSession() { + const sessions = useSessions.getState() + if (!sessions.currentSession) return + try { + await sessions.refreshMessages() + } catch (err) { + console.warn("[Events] Failed to reconcile open session after reconnect:", err) + } +} + export const useEvents = create((set, get) => ({ connected: false, authError: false, @@ -232,6 +263,10 @@ export const useEvents = create((set, get) => ({ if (isReconnect && !resyncedAfterReconnect) { resyncedAfterReconnect = true void resyncBusySessions() + // Backfill content missed while the stream was down. Separate from + // resyncBusySessions(), which only repairs *status* and only for + // sessions already known to be busy — see reconcileOpenSession(). + void reconcileOpenSession() } const payload = (event as any).payload || event