Skip to content

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

@athal7

Description

@athal7

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

Metadata

Metadata

Assignees

Labels

webRelates to opencode on web / desktop

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions