From 297f5298cc8f5320401854033746e37b7e4c67a9 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:26:57 +0800 Subject: [PATCH] fix(app): deduplicate notification sounds across tabs (#44612) --- packages/app/src/app.tsx | 26 +++++++------- .../src/shell/notifications/notification.tsx | 26 +++++++++----- packages/app/src/shell/notifications/sound.ts | 34 +++++++++++++++++++ packages/app/src/shell/tabs/tabs.test.ts | 11 +++++- packages/app/src/shell/tabs/tabs.tsx | 6 +++- 5 files changed, 79 insertions(+), 24 deletions(-) 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), ) }