Skip to content

Commit 298a714

Browse files
committed
fix(app): handle iOS backgrounding - resync session and reconnect SSE on resume
- Add visibilitychange listener in session.tsx to resync when tab becomes visible - Refactor global-sdk.tsx SSE consumption with auto-reconnect on clean disconnect - Use exponential backoff (1s-30s) and pause reconnects while hidden - Add visibility tracking utility to detect recent backgrounding - Gate error toasts in prompt-input.tsx when failure likely due to backgrounding - Check if message landed before removing optimistic UI on resume Fixes anomalyco#10721
1 parent 6c9b2c3 commit 298a714

File tree

5 files changed

+216
-27
lines changed

5 files changed

+216
-27
lines changed

packages/app/src/components/prompt-input.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/
6161
import { Binary } from "@opencode-ai/util/binary"
6262
import { showToast } from "@opencode-ai/ui/toast"
6363
import { base64Encode } from "@opencode-ai/util/encode"
64+
import { wasRecentlyBackgrounded } from "@/utils/visibility"
6465

6566
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
6667
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -1263,10 +1264,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
12631264
command: text,
12641265
})
12651266
.catch((err) => {
1266-
showToast({
1267-
title: language.t("prompt.toast.shellSendFailed.title"),
1268-
description: errorMessage(err),
1269-
})
1267+
// Suppress toast if failure likely due to iOS backgrounding
1268+
if (!wasRecentlyBackgrounded()) {
1269+
showToast({
1270+
title: language.t("prompt.toast.shellSendFailed.title"),
1271+
description: errorMessage(err),
1272+
})
1273+
}
12701274
restoreInput()
12711275
})
12721276
return
@@ -1295,10 +1299,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
12951299
})),
12961300
})
12971301
.catch((err) => {
1298-
showToast({
1299-
title: language.t("prompt.toast.commandSendFailed.title"),
1300-
description: errorMessage(err),
1301-
})
1302+
// Suppress toast if failure likely due to iOS backgrounding
1303+
if (!wasRecentlyBackgrounded()) {
1304+
showToast({
1305+
title: language.t("prompt.toast.commandSendFailed.title"),
1306+
description: errorMessage(err),
1307+
})
1308+
}
13021309
restoreInput()
13031310
})
13041311
return
@@ -1596,11 +1603,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
15961603
})
15971604
}
15981605

1599-
void send().catch((err) => {
1606+
void send().catch(async (err) => {
16001607
pending.delete(session?.id || "")
16011608
if (sessionDirectory === projectDirectory && session?.id) {
16021609
sync.set("session_status", session?.id, { type: "idle" })
16031610
}
1611+
// If we just resumed from background, the request may have actually succeeded
1612+
// Resync to check before showing error and removing optimistic message
1613+
if (session?.id && wasRecentlyBackgrounded()) {
1614+
await sync.session.sync(session.id).catch(() => {})
1615+
// Check if message actually landed
1616+
const messages = sync.data.message[session.id] ?? []
1617+
const found = messages.find((m) => m.id === messageID)
1618+
if (found) return // Message was delivered, don't show error
1619+
}
16041620
showToast({
16051621
title: language.t("prompt.toast.promptSendFailed.title"),
16061622
description: errorMessage(err),

packages/app/src/context/global-sdk.tsx

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,28 +67,83 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
6767
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
6868
}
6969

70-
void (async () => {
71-
const events = await eventSdk.global.event()
72-
let yielded = Date.now()
73-
for await (const event of events.stream) {
74-
const directory = event.directory ?? "global"
75-
const payload = event.payload
76-
const k = key(directory, payload)
77-
if (k) {
78-
const i = coalesced.get(k)
79-
if (i !== undefined) {
80-
queue[i] = undefined
70+
// SSE reconnection with exponential backoff (fixes iOS backgrounding)
71+
const connectSSE = async () => {
72+
const BASE_DELAY = 1000
73+
const MAX_DELAY = 30000
74+
let attempt = 0
75+
76+
while (!abort.signal.aborted) {
77+
try {
78+
const events = await eventSdk.global.event()
79+
attempt = 0 // Reset on successful connection
80+
81+
let yielded = Date.now()
82+
for await (const event of events.stream) {
83+
const directory = event.directory ?? "global"
84+
const payload = event.payload
85+
const k = key(directory, payload)
86+
if (k) {
87+
const i = coalesced.get(k)
88+
if (i !== undefined) {
89+
queue[i] = undefined
90+
}
91+
coalesced.set(k, queue.length)
92+
}
93+
queue.push({ directory, payload })
94+
schedule()
95+
96+
if (Date.now() - yielded < 8) continue
97+
yielded = Date.now()
98+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
8199
}
82-
coalesced.set(k, queue.length)
100+
101+
// Stream ended cleanly (e.g., iOS backgrounding) - reconnect
102+
if (abort.signal.aborted) break
103+
} catch {
104+
// Connection error - will retry with backoff
105+
if (abort.signal.aborted) break
83106
}
84-
queue.push({ directory, payload })
85-
schedule()
86107

87-
if (Date.now() - yielded < 8) continue
88-
yielded = Date.now()
89-
await new Promise<void>((resolve) => setTimeout(resolve, 0))
108+
// Exponential backoff before reconnecting
109+
attempt++
110+
const delay = Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY)
111+
112+
// Wait for delay, but also listen for abort
113+
await new Promise<void>((resolve) => {
114+
const timeout = setTimeout(resolve, delay)
115+
const cleanup = () => {
116+
clearTimeout(timeout)
117+
resolve()
118+
}
119+
abort.signal.addEventListener("abort", cleanup, { once: true })
120+
})
121+
122+
// If hidden, wait until visible before reconnecting (saves resources)
123+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
124+
await new Promise<void>((resolve) => {
125+
const handler = () => {
126+
if (document.visibilityState === "visible") {
127+
document.removeEventListener("visibilitychange", handler)
128+
resolve()
129+
}
130+
}
131+
document.addEventListener("visibilitychange", handler)
132+
// Also resolve if aborted
133+
abort.signal.addEventListener(
134+
"abort",
135+
() => {
136+
document.removeEventListener("visibilitychange", handler)
137+
resolve()
138+
},
139+
{ once: true },
140+
)
141+
})
142+
}
90143
}
91-
})()
144+
}
145+
146+
void connectSSE()
92147
.finally(flush)
93148
.catch(() => undefined)
94149

packages/app/src/pages/session.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
} from "@/components/session"
7171
import { navMark, navParams } from "@/utils/perf"
7272
import { same } from "@/utils/same"
73+
import { wasHiddenFor } from "@/utils/visibility"
7374

7475
type DiffStyle = "unified" | "split"
7576

@@ -551,6 +552,17 @@ export default function Page() {
551552
sync.session.sync(params.id)
552553
})
553554

555+
// Resync session when tab becomes visible after being backgrounded (iOS fix)
556+
onMount(() => {
557+
const handler = () => {
558+
if (document.visibilityState === "hidden") return
559+
if (!wasHiddenFor(1000)) return
560+
if (params.id) sync.session.sync(params.id)
561+
}
562+
document.addEventListener("visibilitychange", handler)
563+
onCleanup(() => document.removeEventListener("visibilitychange", handler))
564+
})
565+
554566
createEffect(() => {
555567
if (!view().terminal.opened()) {
556568
setUi("autoCreated", false)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, test } from "bun:test"
2+
3+
describe("visibility", () => {
4+
test("wasRecentlyBackgrounded returns false when never hidden", async () => {
5+
const { wasRecentlyBackgrounded } = await import("./visibility")
6+
expect(wasRecentlyBackgrounded()).toBe(false)
7+
})
8+
9+
test("wasRecentlyBackgrounded returns true after background/foreground cycle", async () => {
10+
const { wasRecentlyBackgrounded } = await import("./visibility")
11+
12+
// Simulate hiding
13+
Object.defineProperty(document, "visibilityState", {
14+
value: "hidden",
15+
writable: true,
16+
configurable: true,
17+
})
18+
document.dispatchEvent(new Event("visibilitychange"))
19+
20+
await new Promise((r) => setTimeout(r, 50))
21+
22+
// Simulate showing
23+
Object.defineProperty(document, "visibilityState", {
24+
value: "visible",
25+
writable: true,
26+
configurable: true,
27+
})
28+
document.dispatchEvent(new Event("visibilitychange"))
29+
30+
expect(wasRecentlyBackgrounded(5000)).toBe(true)
31+
})
32+
33+
test("wasRecentlyBackgrounded respects threshold", async () => {
34+
const { wasRecentlyBackgrounded } = await import("./visibility")
35+
36+
// Simulate hide then show
37+
Object.defineProperty(document, "visibilityState", {
38+
value: "hidden",
39+
writable: true,
40+
configurable: true,
41+
})
42+
document.dispatchEvent(new Event("visibilitychange"))
43+
44+
await new Promise((r) => setTimeout(r, 50))
45+
46+
Object.defineProperty(document, "visibilityState", {
47+
value: "visible",
48+
writable: true,
49+
configurable: true,
50+
})
51+
document.dispatchEvent(new Event("visibilitychange"))
52+
53+
// Wait past a short threshold
54+
await new Promise((r) => setTimeout(r, 100))
55+
56+
expect(wasRecentlyBackgrounded(50)).toBe(false)
57+
expect(wasRecentlyBackgrounded(5000)).toBe(true)
58+
})
59+
60+
test("wasHiddenFor returns true when hidden long enough", async () => {
61+
const { wasHiddenFor } = await import("./visibility")
62+
63+
// Simulate hide
64+
Object.defineProperty(document, "visibilityState", {
65+
value: "hidden",
66+
writable: true,
67+
configurable: true,
68+
})
69+
document.dispatchEvent(new Event("visibilitychange"))
70+
71+
await new Promise((r) => setTimeout(r, 100))
72+
73+
// Simulate show
74+
Object.defineProperty(document, "visibilityState", {
75+
value: "visible",
76+
writable: true,
77+
configurable: true,
78+
})
79+
document.dispatchEvent(new Event("visibilitychange"))
80+
81+
expect(wasHiddenFor(50)).toBe(true)
82+
expect(wasHiddenFor(500)).toBe(false)
83+
})
84+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Tracks page visibility state for detecting iOS backgrounding scenarios
2+
// Used to suppress error toasts that occur due to background network disconnects
3+
4+
let lastHidden = 0
5+
let lastVisible = Date.now()
6+
7+
if (typeof document !== "undefined") {
8+
document.addEventListener("visibilitychange", () => {
9+
if (document.visibilityState === "hidden") lastHidden = Date.now()
10+
else lastVisible = Date.now()
11+
})
12+
}
13+
14+
/** Returns true if the page was recently backgrounded (within threshold ms) */
15+
export function wasRecentlyBackgrounded(threshold = 5000): boolean {
16+
return lastHidden > 0 && Date.now() - lastVisible < threshold
17+
}
18+
19+
/** Returns true if page was hidden for at least `duration` ms before becoming visible */
20+
export function wasHiddenFor(duration: number): boolean {
21+
return lastHidden > 0 && lastVisible - lastHidden > duration
22+
}

0 commit comments

Comments
 (0)