From 3c5423305e833822d2f16447f0cdeb3c2abb98dd Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:10:13 -0600 Subject: [PATCH] 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 #10721 --- .../app/src/components/prompt-input/submit.ts | 33 +++++-- packages/app/src/context/global-sdk.tsx | 91 +++++++++++++++---- packages/app/src/pages/session.tsx | 14 ++- packages/app/src/utils/visibility.test.ts | 84 +++++++++++++++++ packages/app/src/utils/visibility.ts | 22 +++++ 5 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 packages/app/src/utils/visibility.test.ts create mode 100644 packages/app/src/utils/visibility.ts diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 5ed5eedadae3..f621b304b1c4 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -13,6 +13,7 @@ import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" +import { wasRecentlyBackgrounded } from "@/utils/visibility" import type { FileSelection } from "@/context/file" import { setCursorPosition } from "./editor-dom" import { buildRequestParts } from "./build-request-parts" @@ -240,10 +241,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { command: text, }) .catch((err) => { - showToast({ - title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), - }) + // Suppress toast if failure likely due to iOS backgrounding + if (!wasRecentlyBackgrounded()) { + showToast({ + title: language.t("prompt.toast.shellSendFailed.title"), + description: errorMessage(err), + }) + } restoreInput() }) return @@ -272,10 +276,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { })), }) .catch((err) => { - showToast({ - title: language.t("prompt.toast.commandSendFailed.title"), - description: errorMessage(err), - }) + // Suppress toast if failure likely due to iOS backgrounding + if (!wasRecentlyBackgrounded()) { + showToast({ + title: language.t("prompt.toast.commandSendFailed.title"), + description: errorMessage(err), + }) + } restoreInput() }) return @@ -389,11 +396,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } - void send().catch((err) => { + void send().catch(async (err) => { pending.delete(session.id) if (sessionDirectory === projectDirectory) { sync.set("session_status", session.id, { type: "idle" }) } + // If we just resumed from background, the request may have actually succeeded + // Resync to check before showing error and removing optimistic message + if (wasRecentlyBackgrounded()) { + await sync.session.sync(session.id).catch(() => {}) + const messages = sync.data.message[session.id] ?? [] + const found = messages.find((m) => m.id === messageID) + if (found) return // Message was delivered, don't show error + } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), description: errorMessage(err), diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 0cd4f6c997ea..d1ced2ad6dcc 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -67,28 +67,83 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo timer = setTimeout(flush, Math.max(0, 16 - elapsed)) } - void (async () => { - const events = await eventSdk.global.event() - let yielded = Date.now() - for await (const event of events.stream) { - const directory = event.directory ?? "global" - const payload = event.payload - const k = key(directory, payload) - if (k) { - const i = coalesced.get(k) - if (i !== undefined) { - queue[i] = undefined + // SSE reconnection with exponential backoff (fixes iOS backgrounding) + const connectSSE = async () => { + const BASE_DELAY = 1000 + const MAX_DELAY = 30000 + let attempt = 0 + + while (!abort.signal.aborted) { + try { + const events = await eventSdk.global.event() + attempt = 0 // Reset on successful connection + + let yielded = Date.now() + for await (const event of events.stream) { + const directory = event.directory ?? "global" + const payload = event.payload + const k = key(directory, payload) + if (k) { + const i = coalesced.get(k) + if (i !== undefined) { + queue[i] = undefined + } + coalesced.set(k, queue.length) + } + queue.push({ directory, payload }) + schedule() + + if (Date.now() - yielded < 8) continue + yielded = Date.now() + await new Promise((resolve) => setTimeout(resolve, 0)) } - coalesced.set(k, queue.length) + + // Stream ended cleanly (e.g., iOS backgrounding) - reconnect + if (abort.signal.aborted) break + } catch { + // Connection error - will retry with backoff + if (abort.signal.aborted) break } - queue.push({ directory, payload }) - schedule() - if (Date.now() - yielded < 8) continue - yielded = Date.now() - await new Promise((resolve) => setTimeout(resolve, 0)) + // Exponential backoff before reconnecting + attempt++ + const delay = Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY) + + // Wait for delay, but also listen for abort + await new Promise((resolve) => { + const timeout = setTimeout(resolve, delay) + const cleanup = () => { + clearTimeout(timeout) + resolve() + } + abort.signal.addEventListener("abort", cleanup, { once: true }) + }) + + // If hidden, wait until visible before reconnecting (saves resources) + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + await new Promise((resolve) => { + const handler = () => { + if (document.visibilityState === "visible") { + document.removeEventListener("visibilitychange", handler) + resolve() + } + } + document.addEventListener("visibilitychange", handler) + // Also resolve if aborted + abort.signal.addEventListener( + "abort", + () => { + document.removeEventListener("visibilitychange", handler) + resolve() + }, + { once: true }, + ) + }) + } } - })() + } + + void connectSSE() .finally(flush) .catch(() => undefined) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a70d4e8a2757..d2c5dfbc1c3a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" @@ -58,6 +58,7 @@ import { SessionPromptDock } from "@/pages/session/session-prompt-dock" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { wasHiddenFor } from "@/utils/visibility" type HandoffSession = { prompt: string @@ -670,6 +671,17 @@ export default function Page() { sync.session.sync(id) }) + // Resync session when tab becomes visible after being backgrounded (iOS fix) + onMount(() => { + const handler = () => { + if (document.visibilityState === "hidden") return + if (!wasHiddenFor(1000)) return + if (params.id) sync.session.sync(params.id) + } + document.addEventListener("visibilitychange", handler) + onCleanup(() => document.removeEventListener("visibilitychange", handler)) + }) + createEffect(() => { if (!view().terminal.opened()) { setUi("autoCreated", false) diff --git a/packages/app/src/utils/visibility.test.ts b/packages/app/src/utils/visibility.test.ts new file mode 100644 index 000000000000..d4779c680131 --- /dev/null +++ b/packages/app/src/utils/visibility.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test" + +describe("visibility", () => { + test("wasRecentlyBackgrounded returns false when never hidden", async () => { + const { wasRecentlyBackgrounded } = await import("./visibility") + expect(wasRecentlyBackgrounded()).toBe(false) + }) + + test("wasRecentlyBackgrounded returns true after background/foreground cycle", async () => { + const { wasRecentlyBackgrounded } = await import("./visibility") + + // Simulate hiding + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event("visibilitychange")) + + await new Promise((r) => setTimeout(r, 50)) + + // Simulate showing + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event("visibilitychange")) + + expect(wasRecentlyBackgrounded(5000)).toBe(true) + }) + + test("wasRecentlyBackgrounded respects threshold", async () => { + const { wasRecentlyBackgrounded } = await import("./visibility") + + // Simulate hide then show + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event("visibilitychange")) + + await new Promise((r) => setTimeout(r, 50)) + + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event("visibilitychange")) + + // Wait past a short threshold + await new Promise((r) => setTimeout(r, 100)) + + expect(wasRecentlyBackgrounded(50)).toBe(false) + expect(wasRecentlyBackgrounded(5000)).toBe(true) + }) + + test("wasHiddenFor returns true when hidden long enough", async () => { + const { wasHiddenFor } = await import("./visibility") + + // Simulate hide + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event("visibilitychange")) + + await new Promise((r) => setTimeout(r, 100)) + + // Simulate show + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event("visibilitychange")) + + expect(wasHiddenFor(50)).toBe(true) + expect(wasHiddenFor(500)).toBe(false) + }) +}) diff --git a/packages/app/src/utils/visibility.ts b/packages/app/src/utils/visibility.ts new file mode 100644 index 000000000000..6de83357adc8 --- /dev/null +++ b/packages/app/src/utils/visibility.ts @@ -0,0 +1,22 @@ +// Tracks page visibility state for detecting iOS backgrounding scenarios +// Used to suppress error toasts that occur due to background network disconnects + +let lastHidden = 0 +let lastVisible = Date.now() + +if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") lastHidden = Date.now() + else lastVisible = Date.now() + }) +} + +/** Returns true if the page was recently backgrounded (within threshold ms) */ +export function wasRecentlyBackgrounded(threshold = 5000): boolean { + return lastHidden > 0 && Date.now() - lastVisible < threshold +} + +/** Returns true if page was hidden for at least `duration` ms before becoming visible */ +export function wasHiddenFor(duration: number): boolean { + return lastHidden > 0 && lastVisible - lastHidden > duration +}