-
Notifications
You must be signed in to change notification settings - Fork 12.2k
Description
Description
When OpenCode's web UI is backgrounded on iOS (app switch, screen lock, or tab switch) and later resumed:
- The prompt draft is preserved (good ✓)
- The conversation history appears reverted to an earlier state
- Manual page refresh immediately fixes the history
- One or more error toast notifications appear during or after resume
This happens without user interaction other than backgrounding the browser tab.
Steps to Reproduce
- Open an existing OpenCode session in iOS Safari or Firefox
- Generate or continue a conversation (send a prompt, wait for response)
- Background the browser (app switch or lock screen)
- Wait ~10–60 seconds
- Return to the tab
Result:
- History appears rolled back (missing recent messages)
- Error toast(s) shown
- Manual refresh restores correct history
Expected Behavior
On returning to the foreground:
- Session history is re-synced automatically
- Conversation reflects the latest server state without requiring a manual refresh
- Background network disconnects do not surface user-visible error toasts
Root Cause Analysis (Code Pointers)
1. SSE stream stops permanently on iOS backgrounding
File: packages/app/src/context/global-sdk.tsx (lines 66-89)
The app subscribes to /global/event via:
const events = await eventSdk.global.event()
for await (const event of events.stream) { ... }When iOS suspends the tab, the SSE connection often closes "cleanly" (not as an exception). The generated SSE client in packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts (line 218) has:
break // exit loop on normal completionThis means the stream ends permanently—no reconnect attempt—until the user manually refreshes.
2. Server doesn't emit SSE id: fields (no resume capability)
File: packages/opencode/src/server/server.ts (lines 180-191, 2769-2778)
Both /global/event and /event endpoints call stream.writeSSE({ data: ... }) without an id: field. The client tracks lastEventId and sends Last-Event-ID on reconnect, but the server never provides IDs, so even if reconnect worked, there's no replay of missed events.
3. No visibility-triggered resync
File: packages/app/src/pages/session.tsx (lines 299-302)
Session data is synced on mount/route change:
createEffect(() => {
if (!params.id) return
sync.session.sync(params.id)
})But there's no visibilitychange or pageshow listener to resync when the tab becomes visible again after backgrounding.
4. Optimistic message removal on network failure shows "rollback"
File: packages/app/src/components/prompt-input.tsx (lines 1284-1291)
When sending a prompt, an optimistic message is added immediately. On failure:
.catch((err) => {
showToast({ title: "Failed to send prompt", description: errorMessage(err) })
removeOptimisticMessage()
restoreInput()
})If iOS suspends the tab mid-request and the request fails/times out on resume, the UI removes the optimistic message (appearing as "rollback") and shows an error toast—even though the message may have actually been delivered server-side.
5. Existing visibility handling only flushes scroll state
File: packages/app/src/context/layout.tsx (lines 172-187)
There's already a visibilitychange listener, but it only flushes scroll persistence—it doesn't trigger data resync:
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
flush() // only flushes scroll state
}Suggested Fix Direction
Option A: Client-side resume reconciliation (Recommended, low risk)
-
Add visibility-triggered resync in
packages/app/src/pages/session.tsx:- On
visibilitychange(hidden → visible), callsync.session.sync(sessionID) - Debounce to avoid multiple rapid resyncs
- On
-
Gate error toasts during background in
packages/app/src/components/prompt-input.tsx:- Track when tab was last hidden
- If failure occurs shortly after resume, suppress toast and trigger resync instead of removing optimistic message
- Only revert UI if resync confirms the message didn't land
Option B: Make SSE reconnect on clean disconnect (Recommended alongside A)
In packages/app/src/context/global-sdk.tsx:
- Wrap the
for awaitloop in a reconnect loop - If stream ends (not aborted), re-subscribe with exponential backoff
- Optionally pause reconnect attempts while
document.hidden === true
Option C: Server-side event IDs + replay (Future enhancement)
- Emit SSE
id:fields in/global/eventand/event - Keep a small ring buffer per directory for replay on reconnect
- Higher complexity, but most correct for short disconnect windows
Additional Notes
- This affects usability on iOS even when used as a PWA
- Draft persistence already works correctly; issue is limited to session history + stream lifecycle
- The 30-second heartbeat in the server (lines 196-206) helps with WKWebView timeout but doesn't prevent iOS from killing the connection on background
- Happy to test patches or provide logs if needed