diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 00774730e5a..774fe34540d 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -10,6 +10,7 @@ import { createData } from "@opencode/client/solid" import type { ServerScope } from "@/runtime/server/scope" import { createPermissionAutoApprover } from "@/session/requests/auto-approve" import { createServerNotificationState } from "@/shell/notifications/notification" +import { createNotificationCoordinator } from "@/shell/notifications/coordinator" import { Persist, persisted } from "@/runtime/persistence/storage" import { createDesktopData } from "./data" import { ModelState } from "./persistence" @@ -33,6 +34,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( }, }) const models = createGlobalModels() + const notificationCoordinator = createNotificationCoordinator() const settingsServer = createMemo(() => { const list = server.list @@ -57,7 +59,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( if (existing) return existing const serverCtx = createRoot((dispose) => { serverCtxDisposers.set(key, dispose) - return createServerController(conn, server.scope(key), server.projects.forServer(key)) + return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator) }, owner) serverCtxs.set(key, serverCtx) return serverCtx @@ -131,6 +133,7 @@ function createServerController( conn: ServerConnection.Any, scope: ServerScope, projects: ReturnType, + notificationCoordinator: ReturnType, ) { const language = useLanguage() const settings = useSettings() @@ -159,7 +162,7 @@ function createServerController( }) const sync = createServerSyncContext(sdk, data) createPermissionAutoApprover({ sdk, data }) - const notification = createServerNotificationState({ sdk, data, key: connKey }) + const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator }) function enrich(project: { worktree: string; expanded: boolean }) { const [childStore] = sync.child(project.worktree, { bootstrap: false }) diff --git a/packages/app/src/shell/notifications/coordinator.ts b/packages/app/src/shell/notifications/coordinator.ts new file mode 100644 index 00000000000..abde0026e2a --- /dev/null +++ b/packages/app/src/shell/notifications/coordinator.ts @@ -0,0 +1,93 @@ +import { onCleanup } from "solid-js" + +const FOCUS_LOCK = "opencode:notification-focus" +const MAX_CLAIMED = 500 + +export function createNotificationCoordinator() { + const locks = typeof navigator === "undefined" ? undefined : navigator.locks + const claimed = new Set() + const focus = { pending: false, release: undefined as (() => void) | undefined } + + const updateFocus = () => { + if (typeof document === "undefined" || !document.hasFocus()) { + focus.release?.() + return + } + if (!locks || focus.pending || focus.release) return + + focus.pending = true + void locks + .request(FOCUS_LOCK, { mode: "shared" }, async () => { + focus.pending = false + if (!document.hasFocus()) return + await new Promise((resolve) => { + focus.release = resolve + }) + focus.release = undefined + }) + .catch(() => { + focus.pending = false + }) + } + + if (typeof window !== "undefined") { + window.addEventListener("focus", updateFocus) + window.addEventListener("blur", updateFocus) + document.addEventListener("visibilitychange", updateFocus) + updateFocus() + onCleanup(() => { + window.removeEventListener("focus", updateFocus) + window.removeEventListener("blur", updateFocus) + document.removeEventListener("visibilitychange", updateFocus) + focus.release?.() + }) + } + + const once = async (kind: "sound" | "system", eventID: string, run: () => Promise | void) => { + const key = `${kind}:${eventID}` + const execute = async () => { + if (!claim(kind, key, claimed)) return + await run() + } + if (!locks) return execute() + await locks.request(`opencode:notification:${key}`, execute) + } + + return { + sound(eventID: string, run: () => Promise | void) { + return once("sound", eventID, run) + }, + system(eventID: string, run: () => Promise | void) { + return once("system", eventID, async () => { + if (typeof document !== "undefined" && document.hasFocus()) return + if (!locks) return run() + await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => { + if (!lock) return + await run() + }) + }) + }, + } +} + +function claim(kind: "sound" | "system", eventID: string, claimed: Set) { + if (claimed.has(eventID)) return false + + if (typeof localStorage !== "undefined") { + try { + const storageKey = `opencode:notification-${kind}` + const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]") + const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] + if (events.includes(eventID)) { + claimed.add(eventID) + return false + } + localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED))) + } catch { + // The in-memory claim still prevents duplicates in this renderer when storage is unavailable. + } + } + + claimed.add(eventID) + return true +} diff --git a/packages/app/src/shell/notifications/notification.tsx b/packages/app/src/shell/notifications/notification.tsx index 69b833f3d8b..55e435fb23e 100644 --- a/packages/app/src/shell/notifications/notification.tsx +++ b/packages/app/src/shell/notifications/notification.tsx @@ -11,7 +11,8 @@ import { useSettings } from "@/settings/model" import { decode64 } from "@/runtime/persistence/base64" import { Persist, persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" -import { playSoundByIdOnce } from "@/shell/notifications/sound" +import { playSoundById } from "@/shell/notifications/sound" +import type { createNotificationCoordinator } from "@/shell/notifications/coordinator" import { useGlobal } from "@/runtime/server/runtime" import { ServerConnection, useServers } from "@/runtime/server/registry" import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs" @@ -114,7 +115,12 @@ function buildNotificationIndex(list: Notification[]) { return index } -export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) { +export function createServerNotificationState(input: { + sdk: ServerSDK + data: Data + key: ServerConnection.Key + coordinator: ReturnType +}) { const platform = usePlatform() const settings = useSettings() const language = useLanguage() @@ -223,7 +229,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat if (session.parentID) return if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) { - void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`) + void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent())) } append({ @@ -235,8 +241,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat }) if (settings.notifications.agent()) { - void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () => - openNotificationSession(tabs, input.key, sessionID), + void input.coordinator.system(`${input.key}\0${eventID}`, () => + platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () => + openNotificationSession(tabs, input.key, sessionID), + ), ) } }) @@ -248,7 +256,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat if (session?.parentID) return if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) { - void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`) + void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors())) } append({ @@ -263,8 +271,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat session?.title ?? (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, () => - openNotificationSession(tabs, input.key, sessionID), + void input.coordinator.system(`${input.key}\0${eventID}`, () => + platform.notify(language.t("notification.session.error.title"), description, () => + openNotificationSession(tabs, input.key, sessionID), + ), ) } }) diff --git a/packages/app/src/shell/notifications/sound.ts b/packages/app/src/shell/notifications/sound.ts index 8d356ea7632..8195d131b7e 100644 --- a/packages/app/src/shell/notifications/sound.ts +++ b/packages/app/src/shell/notifications/sound.ts @@ -74,9 +74,6 @@ function getLoads() { } const cache = new Map>() -const claimed = new Set() -const CLAIMED_STORAGE_KEY = "opencode:notification-sounds" -const MAX_CLAIMED = 500 export function soundSrc(id: string | undefined) { const loads = getLoads() @@ -103,34 +100,3 @@ export function playSound(src: string | undefined) { export function playSoundById(id: string | undefined) { return soundSrc(id).then((src) => playSound(src)) } - -export async function playSoundByIdOnce(id: string | undefined, eventID: string) { - const play = async () => { - if (!claim(eventID)) return - await playSoundById(id) - } - - if (typeof navigator === "undefined" || !navigator.locks) return play() - await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play) -} - -function claim(eventID: string) { - if (claimed.has(eventID)) return false - - if (typeof localStorage !== "undefined") { - try { - const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]") - const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] - if (events.includes(eventID)) { - claimed.add(eventID) - return false - } - localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED))) - } catch { - // The in-memory claim still prevents duplicates in this renderer when storage is unavailable. - } - } - - claimed.add(eventID) - return true -} diff --git a/packages/desktop/src/renderer/platform/notifications.ts b/packages/desktop/src/renderer/platform/notifications.ts index c9a7ebe95eb..385236fc9f1 100644 --- a/packages/desktop/src/renderer/platform/notifications.ts +++ b/packages/desktop/src/renderer/platform/notifications.ts @@ -9,6 +9,7 @@ export function createDesktopNotify(api: ElectronAPI): Platform["notify"] { const notification = new Notification(title, { body: description ?? "", icon: "https://opencode.ai/favicon-96x96-v3.png", + silent: true, }) notification.onclick = () => { void api.showWindow()