Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/stores/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,16 @@ export const useEvents = create<EventsState>((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 } }`,
Expand Down
41 changes: 41 additions & 0 deletions src/stores/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ interface SessionsState {
) => Promise<void>
abortSession: () => Promise<void>
refreshMessages: () => Promise<void>
// 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<void>

// Revert (edit sent message) / unrevert (undo the pending revert)
revertToMessage: (messageID: string) => Promise<RevertResult>
Expand Down Expand Up @@ -382,6 +386,43 @@ export const useSessions = create<SessionsState>((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
Expand Down
Loading