From 3667652c429797e63d15a211261e0da5d8756717 Mon Sep 17 00:00:00 2001 From: Antigravity Assistant Date: Sat, 8 Aug 2026 17:05:02 +0530 Subject: [PATCH] fix: live-stream continuity across SSE reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE resumes from 'now' after a reconnect — it never replays events that streamed while the connection was down (mobile OS idle kill during a long thinking gap, LAN blip, app backgrounding). The old resync only recovered busy->idle transitions, so tokens that arrived during the gap were lost forever and the answer appeared 'cut'. - sessions.ts: add catchUpSessionMessages() — re-fetch the current session's messages/parts and merge with on-screen state, preserving temp- optimistic placeholders (established loadOlderMessages merge pattern). - events.ts: fire the catch-up once per re-established stream, alongside the existing busy-session resync. --- src/stores/events.ts | 10 ++++++++++ src/stores/sessions.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/stores/events.ts b/src/stores/events.ts index 47913433..af6b76a5 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -227,6 +227,16 @@ export const useEvents = create((set, get) => ({ if (!resyncedAfterStream) { resyncedAfterStream = true void resyncBusySessions() + // Live-stream continuity (issue #10288 mobile): SSE resumes from + // "now" and never replays events that streamed while the stream + // was down. If the connection dropped mid-generation (OS idle + // kill during a long thinking gap, LAN blip, backgrounding), the + // tokens that arrived in the gap are lost unless we re-pull. The + // busy resync above only recovers busy->idle transitions; this + // recovers ACTUAL ANSWER CONTENT for the session on screen, + // keeping the live stream continuous across reconnects. + const current = useSessions.getState().currentSession + if (current) void useSessions.getState().catchUpSessionMessages(current.id) } // Some server versions wrap events as `{ payload: { type, properties } }`, diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 6bb53df6..e786c2c6 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -55,6 +55,10 @@ interface SessionsState { ) => Promise abortSession: () => Promise refreshMessages: () => Promise + // Re-pull the current session's messages and merge with what's on screen + // after an SSE reconnect — recovers tokens that streamed during the gap + // (SSE does not replay missed events). No-op unless the session is open. + catchUpSessionMessages: (sessionID: string) => Promise // Revert (edit sent message) / unrevert (undo the pending revert) revertToMessage: (messageID: string) => Promise @@ -382,6 +386,43 @@ export const useSessions = create((set, get) => ({ } }, + // Live-stream catch-up: re-fetch the session's messages/parts and MERGE + // them into the on-screen state. SSE resumes from "now" after a reconnect + // — it never replays events that streamed while the connection was down — + // so if the stream dropped mid-generation (mobile OS idle kill, LAN blip, + // app backgrounded), the tokens that arrived during the gap would be lost + // forever without this. Unlike refreshMessages, this preserves `temp-` + // optimistic placeholders so an in-flight send doesn't ghost. + catchUpSessionMessages: async (sessionID: string) => { + const client = clientFor(get().currentSession?.directory) + const session = get().currentSession + if (!client || !session || session.id !== sessionID) return + + try { + const response = await client.session.messages(sessionID) + const { messages, parts } = parseMessages(response) + + set((state) => { + const existing = state.messages + const temp = existing.filter((m) => m.id.startsWith("temp-")) + return { + messages: [...messages, ...temp], + parts: { + ...Object.fromEntries(temp.map((m) => [m.id, state.parts[m.id] || []])), + ...parts, + }, + // A successful catch-up is proof the session has content — clear + // any stuck spinner, never set it back (see handleEvent). + isLoading: false, + } + }) + } catch (err) { + // Fail silent: a live event or the next reconnect retries. Surfacing an + // error here would just add a red banner to an already-flaky moment. + console.warn("[Sessions] Message catch-up failed:", err) + } + }, + // Marks messageID (and everything after it) as pending revert, so the // user can re-edit and resend it. The server keeps the underlying // messages until the next prompt runs cleanup, or unrevertSession() below