diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index d706a21984f..2a16cca7b36 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -105,14 +105,16 @@ export function AppInterface(props: {
// providers beneath it.
const Root = (rootProps: ParentProps) => (
-
-
-
-
- {props.children}
- {rootProps.children}
-
-
+
+
+
+
+
+ {props.children}
+ {rootProps.children}
+
+
+
)
@@ -123,11 +125,9 @@ export function AppInterface(props: {
servers={props.servers}
>
-
-
-
-
-
+
+
+
)
diff --git a/packages/app/src/shell/notifications/notification.tsx b/packages/app/src/shell/notifications/notification.tsx
index 33b54d06039..dfc48b6eca4 100644
--- a/packages/app/src/shell/notifications/notification.tsx
+++ b/packages/app/src/shell/notifications/notification.tsx
@@ -9,10 +9,10 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
-import { playSoundById } from "@/shell/notifications/sound"
+import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
-import { type DraftTab, useTabs } from "@/shell/tabs/tabs"
+import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
@@ -112,6 +112,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
+ const tabs = useTabs()
const empty: Notification[] = []
const [store, setStore, _, ready] = persisted(
@@ -215,14 +216,17 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
- const handleSessionIdle = (sessionID: string, time: number) => {
+ const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
- if (settings.sounds.agentEnabled()) {
- void playSoundById(settings.sounds.agent())
+ if (
+ sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
+ settings.sounds.agentEnabled()
+ ) {
+ void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
}
append({
@@ -245,14 +249,18 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const handleSessionError = (
sessionID: string,
error: ErrorNotification["error"],
+ eventID: string,
time: number,
) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
- if (settings.sounds.errorsEnabled()) {
- void playSoundById(settings.sounds.errors())
+ if (
+ sessionIDHasOpenTab(tabs.store, input.key, sessionID) &&
+ settings.sounds.errorsEnabled()
+ ) {
+ void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
}
append({
@@ -278,10 +286,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const time = Date.now()
if (event.type === "session.execution.failed") {
- handleSessionError(event.data.sessionID, event.data.error, time)
+ handleSessionError(event.data.sessionID, event.data.error, event.id, time)
return
}
- handleSessionIdle(event.data.sessionID, time)
+ handleSessionIdle(event.data.sessionID, event.id, time)
})
onCleanup(() => {
meta.disposed = true
diff --git a/packages/app/src/shell/notifications/sound.ts b/packages/app/src/shell/notifications/sound.ts
index 8195d131b7e..8d356ea7632 100644
--- a/packages/app/src/shell/notifications/sound.ts
+++ b/packages/app/src/shell/notifications/sound.ts
@@ -74,6 +74,9 @@ 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()
@@ -100,3 +103,34 @@ 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/app/src/shell/tabs/tabs.test.ts b/packages/app/src/shell/tabs/tabs.test.ts
index 530ed7807f3..963b620864e 100644
--- a/packages/app/src/shell/tabs/tabs.test.ts
+++ b/packages/app/src/shell/tabs/tabs.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
-import { tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
+import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -47,6 +47,15 @@ test("session tab identity stays rooted while its href follows the child route",
expect(tabHref(child)).toContain("/session/child")
})
+test("finds open root and routed session tabs", () => {
+ const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
+
+ expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
+ expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
+ expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
+ expect(sessionIDHasOpenTab(tabs, "other" as ServerConnection.Key, "root")).toBe(false)
+})
+
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx
index 7a3e8cc3b6d..3f4f854ffb2 100644
--- a/packages/app/src/shell/tabs/tabs.tsx
+++ b/packages/app/src/shell/tabs/tabs.tsx
@@ -51,11 +51,15 @@ export const tabKey = (tab: Tab) =>
tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${sessionHref(tab.server, tab.sessionId)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
+ return sessionIDHasOpenTab(tabs, server, session.id)
+}
+
+export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
(tab) =>
tab.type === "session" &&
tab.server === server &&
- (tab.sessionId === session.id || tab.routeSessionId === session.id),
+ (tab.sessionId === sessionID || tab.routeSessionId === sessionID),
)
}