diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5ec0eb1ea04d..313e7794ea9e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -58,6 +58,7 @@ import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/ import { Binary } from "@opencode-ai/util/binary" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" +import { wasRecentlyBackgrounded } from "@/utils/visibility" const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] @@ -1260,10 +1261,13 @@ export const PromptInput: Component = (props) => { 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 @@ -1292,10 +1296,13 @@ export const PromptInput: Component = (props) => { })), }) .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 @@ -1589,11 +1596,20 @@ export const PromptInput: Component = (props) => { }) } - 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(() => {}) + // Check if message actually landed + 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..013468867893 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -67,29 +67,88 @@ 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 + const stop = () => { + flush() + } + + // 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 wait for visibility if hidden + 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 (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 }, + ) + }) + } } - })() - .finally(flush) + } + + void connectSSE() + .finally(stop) .catch(() => undefined) onCleanup(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e9b29c03e390..286489068316 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -463,6 +463,23 @@ export default function Page() { sync.session.sync(params.id) }) + // Resync session when tab becomes visible (iOS backgrounding fix) + onMount(() => { + let lastHidden = 0 + const handleVisibility = () => { + if (document.visibilityState === "hidden") { + lastHidden = Date.now() + return + } + // Only resync if we were hidden for at least 1 second (avoid spurious triggers) + if (!params.id) return + if (Date.now() - lastHidden < 1000) return + sync.session.sync(params.id) + } + document.addEventListener("visibilitychange", handleVisibility) + onCleanup(() => document.removeEventListener("visibilitychange", handleVisibility)) + }) + createEffect(() => { if (!view().terminal.opened()) { setUi("autoCreated", false) diff --git a/packages/app/src/utils/visibility.ts b/packages/app/src/utils/visibility.ts new file mode 100644 index 000000000000..2ed596d5effc --- /dev/null +++ b/packages/app/src/utils/visibility.ts @@ -0,0 +1,32 @@ +// Tracks page visibility state for detecting iOS backgrounding scenarios +// Used to suppress error toasts that occur due to background network disconnects + +let lastHiddenAt = 0 +let lastVisibleAt = Date.now() + +if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + lastHiddenAt = Date.now() + } else { + lastVisibleAt = Date.now() + } + }) +} + +/** Returns true if the page was recently backgrounded (within the given threshold in ms) */ +export function wasRecentlyBackgrounded(thresholdMs = 5000): boolean { + if (!lastHiddenAt) return false + const timeSinceVisible = Date.now() - lastVisibleAt + return timeSinceVisible < thresholdMs && lastHiddenAt > 0 +} + +/** Returns the timestamp when the page was last hidden, or 0 if never */ +export function getLastHiddenAt(): number { + return lastHiddenAt +} + +/** Returns true if the page is currently hidden */ +export function isHidden(): boolean { + return typeof document !== "undefined" && document.visibilityState === "hidden" +}