iOS Safari/Firefox: session history rolls back after backgrounding until refresh + error toasts on resume #7657

Open
opened 2026-02-16 18:07:50 -05:00 by yindo · 0 comments
Owner

Originally created by @athal7 on GitHub (Jan 26, 2026).

Originally assigned to: @adamdotdevin on GitHub.

Description

When OpenCode's web UI is backgrounded on iOS (app switch, screen lock, or tab switch) and later resumed:

  1. The prompt draft is preserved (good ✓)
  2. The conversation history appears reverted to an earlier state
  3. Manual page refresh immediately fixes the history
  4. 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

  1. Open an existing OpenCode session in iOS Safari or Firefox
  2. Generate or continue a conversation (send a prompt, wait for response)
  3. Background the browser (app switch or lock screen)
  4. Wait ~10–60 seconds
  5. 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 completion

This 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)

  1. Add visibility-triggered resync in packages/app/src/pages/session.tsx:

    • On visibilitychange (hidden → visible), call sync.session.sync(sessionID)
    • Debounce to avoid multiple rapid resyncs
  2. 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 await loop 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/event and /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
Originally created by @athal7 on GitHub (Jan 26, 2026). Originally assigned to: @adamdotdevin on GitHub. ## Description When OpenCode's web UI is backgrounded on iOS (app switch, screen lock, or tab switch) and later resumed: 1. The **prompt draft is preserved** (good ✓) 2. The **conversation history appears reverted** to an earlier state 3. **Manual page refresh immediately fixes** the history 4. 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 1. Open an existing OpenCode session in iOS Safari or Firefox 2. Generate or continue a conversation (send a prompt, wait for response) 3. Background the browser (app switch or lock screen) 4. Wait ~10–60 seconds 5. 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: ```ts 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: ```ts break // exit loop on normal completion ``` This 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: ```ts 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: ```ts .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: ```ts const handleVisibility = () => { if (document.visibilityState !== "hidden") return flush() // only flushes scroll state } ``` --- ## Suggested Fix Direction ### Option A: Client-side resume reconciliation (Recommended, low risk) 1. **Add visibility-triggered resync** in `packages/app/src/pages/session.tsx`: - On `visibilitychange` (hidden → visible), call `sync.session.sync(sessionID)` - Debounce to avoid multiple rapid resyncs 2. **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 await` loop 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/event` and `/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
yindo added the web label 2026-02-16 18:07:50 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#7657