Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
91 changes: 73 additions & 18 deletions packages/app/src/context/global-sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((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<void>((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<void>((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)

Expand Down
14 changes: 13 additions & 1 deletion packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions packages/app/src/utils/visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
22 changes: 22 additions & 0 deletions packages/app/src/utils/visibility.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading