Skip to content
Closed
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
34 changes: 25 additions & 9 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -1260,10 +1261,13 @@ export const PromptInput: Component<PromptInputProps> = (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
Expand Down Expand Up @@ -1292,10 +1296,13 @@ export const PromptInput: Component<PromptInputProps> = (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
Expand Down Expand Up @@ -1589,11 +1596,20 @@ export const PromptInput: Component<PromptInputProps> = (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),
Expand Down
97 changes: 78 additions & 19 deletions packages/app/src/context/global-sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 wait for visibility if hidden
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 (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 },
)
})
}
}
})()
.finally(flush)
}

void connectSSE()
.finally(stop)
.catch(() => undefined)

onCleanup(() => {
Expand Down
17 changes: 17 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions packages/app/src/utils/visibility.ts
Original file line number Diff line number Diff line change
@@ -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"
}