diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 79d09597c9..655854cba0 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -39,7 +39,7 @@ import { CommandPalette } from "./components/CommandPalette/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; import { THINKING_LEVELS, type ThinkingLevel } from "@/common/types/thinking"; -import { CUSTOM_EVENTS } from "@/common/constants/events"; +import { CUSTOM_EVENTS, type CustomEventType } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { getAgentIdKey, @@ -94,6 +94,8 @@ import { getErrorMessage } from "@/common/utils/errors"; import assert from "@/common/utils/assert"; import { createProjectRefs } from "@/common/utils/multiProject"; import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject"; +import type { EventSoundSettings } from "@/common/config/schemas/appConfigOnDisk"; +import { playEventSound } from "@/browser/utils/audio/eventSounds"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { LandingPage } from "@/browser/features/LandingPage/LandingPage"; import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen"; @@ -238,6 +240,55 @@ function AppInner() { workspaceMetadataRef.current = workspaceMetadata; }, [workspaceMetadata]); + const eventSoundSettingsRef = useRef(undefined); + const eventSoundSettingsLoadVersionRef = useRef(0); + useEffect(() => { + const handleEventSoundSettingsChanged = ( + event: CustomEventType + ) => { + eventSoundSettingsLoadVersionRef.current += 1; + eventSoundSettingsRef.current = event.detail.eventSoundSettings; + }; + + window.addEventListener( + CUSTOM_EVENTS.EVENT_SOUND_SETTINGS_CHANGED, + handleEventSoundSettingsChanged as EventListener + ); + + if (!api) { + eventSoundSettingsRef.current = undefined; + return () => { + window.removeEventListener( + CUSTOM_EVENTS.EVENT_SOUND_SETTINGS_CHANGED, + handleEventSoundSettingsChanged as EventListener + ); + }; + } + + let isCancelled = false; + const loadVersion = eventSoundSettingsLoadVersionRef.current; + void api.config + .getConfig() + .then((config) => { + if (!isCancelled && loadVersion === eventSoundSettingsLoadVersionRef.current) { + eventSoundSettingsRef.current = config.eventSoundSettings; + } + }) + .catch((error) => { + if (!isCancelled) { + console.debug("Failed to load event sound settings", { error: String(error) }); + } + }); + + return () => { + isCancelled = true; + window.removeEventListener( + CUSTOM_EVENTS.EVENT_SOUND_SETTINGS_CHANGED, + handleEventSoundSettingsChanged as EventListener + ); + }; + }, [api]); + const handleOpenMuxChat = useCallback(() => { // User requested an F1 shortcut to jump straight into Chat with Mux. const metadata = workspaceMetadataRef.current.get(MUX_HELP_CHAT_WORKSPACE_ID); @@ -1002,6 +1053,9 @@ function AppInner() { return; } + // Play event sound (independent of notification settings). + playEventSound(eventSoundSettingsRef.current, "agent_review_ready", api); + // Skip notification if the selected workspace is focused (Slack-like behavior). // Notification suppression intentionally follows selection state, not chat-route visibility. const isWorkspaceFocused = @@ -1038,7 +1092,7 @@ function AppInner() { return () => { unsubscribe?.(); }; - }, [setSelectedWorkspace, workspaceStore]); + }, [api, setSelectedWorkspace, workspaceStore]); // Show auth modal if authentication is required if (status === "auth_required") { diff --git a/src/browser/components/AppLoader/AppLoader.auth.test.tsx b/src/browser/components/AppLoader/AppLoader.auth.test.tsx index b7a831ffcb..c55c80d3cd 100644 --- a/src/browser/components/AppLoader/AppLoader.auth.test.tsx +++ b/src/browser/components/AppLoader/AppLoader.auth.test.tsx @@ -11,6 +11,8 @@ let cleanupDom: (() => void) | null = null; let apiStatus: "auth_required" | "connecting" | "error" = "auth_required"; let apiError: string | null = "Authentication required"; +const AUTH_TOKEN_STORAGE_KEY = "mux:auth-token"; + // AppLoader imports App, which pulls in Lottie-based components. In happy-dom, // lottie-web's canvas bootstrap can throw during module evaluation. void mock.module("lottie-react", () => ({ @@ -67,14 +69,32 @@ void mock.module("@/browser/components/StartupConnectionError/StartupConnectionE void mock.module("@/browser/components/AuthTokenModal/AuthTokenModal", () => ({ // Note: Module mocks leak between bun test files. // Export all commonly-used symbols to avoid cross-test import errors. + // Keep token helpers behaviorally close to production so leaked mocks do not + // break tests that expect auth-token persistence. AuthTokenModal: (props: { error?: string | null }) => (
{props.error ?? "no-error"}
), - getStoredAuthToken: () => null, - // eslint-disable-next-line @typescript-eslint/no-empty-function - setStoredAuthToken: () => {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function - clearStoredAuthToken: () => {}, + getStoredAuthToken: () => { + try { + return localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + return null; + } + }, + setStoredAuthToken: (token: string) => { + try { + localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); + } catch { + // no-op in tests without storage + } + }, + clearStoredAuthToken: () => { + try { + localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + // no-op in tests without storage + } + }, })); import { AppLoader } from "../AppLoader/AppLoader"; diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index 5cda98f289..6fbaf9126c 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -65,13 +65,44 @@ let fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise Promise = () => Promise.resolve("pong"); +const AUTH_TOKEN_STORAGE_KEY = "mux:auth-token"; let storedAuthToken: string | null = null; -const getStoredAuthTokenMock = mock(() => storedAuthToken); + +function readStoredAuthTokenFromLocalStorage(): string | null { + try { + return localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + return storedAuthToken; + } +} + +function writeStoredAuthTokenToLocalStorage(token: string): void { + try { + localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); + } catch { + // no-op in tests without storage + } +} + +function clearStoredAuthTokenFromLocalStorage(): void { + try { + localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + // no-op in tests without storage + } +} + +const getStoredAuthTokenMock = mock(() => { + storedAuthToken = readStoredAuthTokenFromLocalStorage(); + return storedAuthToken; +}); const setStoredAuthTokenMock = mock((token: string) => { storedAuthToken = token; + writeStoredAuthTokenToLocalStorage(token); }); const clearStoredAuthTokenMock = mock(() => { storedAuthToken = null; + clearStoredAuthTokenFromLocalStorage(); }); void mock.module("@/common/orpc/client", () => ({ @@ -93,6 +124,8 @@ void mock.module("@orpc/client/message-port", () => ({ void mock.module("@/browser/components/AuthTokenModal/AuthTokenModal", () => ({ // Note: Module mocks leak between bun test files. // Export all commonly-used symbols to avoid cross-test import errors. + // Keep token helpers behaviorally close to production so leaked mocks do not + // break tests that expect auth-token persistence. AuthTokenModal: () => null, getStoredAuthToken: getStoredAuthTokenMock, setStoredAuthToken: setStoredAuthTokenMock, diff --git a/src/browser/features/Settings/Sections/SoundsSection.tsx b/src/browser/features/Settings/Sections/SoundsSection.tsx new file mode 100644 index 0000000000..66ef7ccb04 --- /dev/null +++ b/src/browser/features/Settings/Sections/SoundsSection.tsx @@ -0,0 +1,380 @@ +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/browser/components/Button/Button"; +import { Switch } from "@/browser/components/Switch/Switch"; +import { useAPI } from "@/browser/contexts/API"; +import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; +import { + ALLOWED_AUDIO_EXTENSIONS, + EVENT_SOUND_KEYS, + EVENT_SOUND_LABELS, + MAX_AUDIO_FILE_SIZE_BYTES, + type EventSoundKey, +} from "@/common/config/eventSoundTypes"; +import type { EventSoundConfig, EventSoundSettings } from "@/common/config/schemas/appConfigOnDisk"; + +const AUDIO_UPLOAD_ACCEPT = [ + "audio/*", + ...ALLOWED_AUDIO_EXTENSIONS.map((extension) => `.${extension}`), +].join(","); + +function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== "string") { + reject(new Error("Failed to read selected file.")); + return; + } + + const separatorIndex = reader.result.indexOf(","); + if (separatorIndex < 0) { + reject(new Error("Failed to parse selected file.")); + return; + } + + resolve(reader.result.slice(separatorIndex + 1)); + }; + reader.onerror = () => { + reject(new Error("Failed to read selected file.")); + }; + reader.readAsDataURL(file); + }); +} + +function getEventSoundConfig(settings: EventSoundSettings, key: EventSoundKey): EventSoundConfig { + const config = settings?.[key]; + return { + enabled: config?.enabled === true, + source: config?.source ?? null, + }; +} + +function updateEventSoundConfig( + settings: EventSoundSettings, + key: EventSoundKey, + config: EventSoundConfig +): EventSoundSettings { + return { + ...(settings ?? {}), + [key]: config, + }; +} + +function collectManagedAssetIds(settings: EventSoundSettings): Set { + const managedAssetIds = new Set(); + + for (const config of Object.values(settings ?? {})) { + if (config?.source?.kind === "managed") { + managedAssetIds.add(config.source.assetId); + } + } + + return managedAssetIds; +} + +function getUnreferencedManagedAssetIds( + previousSettings: EventSoundSettings, + nextSettings: EventSoundSettings +): string[] { + const previousAssetIds = collectManagedAssetIds(previousSettings); + const nextAssetIds = collectManagedAssetIds(nextSettings); + + return [...previousAssetIds].filter((assetId) => !nextAssetIds.has(assetId)); +} + +export function SoundsSection() { + const { api } = useAPI(); + const [eventSoundSettings, setEventSoundSettings] = useState(undefined); + + const loadNonceRef = useRef(0); + const saveChainRef = useRef>(Promise.resolve()); + const pendingSettingsRef = useRef<{ + hasPending: boolean; + settings: EventSoundSettings; + staleManagedAssetIds: Set; + }>({ + hasPending: false, + settings: undefined, + staleManagedAssetIds: new Set(), + }); + + // Browser mode uses one hidden file input for all rows, so we track which event key opened it. + const uploadInputRef = useRef(null); + const uploadTargetKeyRef = useRef(null); + + useEffect(() => { + if (!api?.config?.getConfig) { + return; + } + + const loadNonce = ++loadNonceRef.current; + + void api.config + .getConfig() + .then((config) => { + if (loadNonce !== loadNonceRef.current) { + return; + } + + setEventSoundSettings(config.eventSoundSettings); + }) + .catch(() => { + // Best-effort only. + }); + }, [api]); + + const queueSettingsSave = (nextSettings: EventSoundSettings, staleManagedAssetIds: string[]) => { + if (!api?.config?.updateEventSoundSettings) { + return; + } + + pendingSettingsRef.current.hasPending = true; + pendingSettingsRef.current.settings = nextSettings; + + for (const staleAssetId of staleManagedAssetIds) { + pendingSettingsRef.current.staleManagedAssetIds.add(staleAssetId); + } + + // If a pending save now references an asset again, do not delete it. + for (const referencedAssetId of collectManagedAssetIds(nextSettings)) { + pendingSettingsRef.current.staleManagedAssetIds.delete(referencedAssetId); + } + + // Serialize writes so rapid toggles/file selections cannot persist out-of-order settings. + saveChainRef.current = saveChainRef.current + .catch(() => { + // Best-effort only. + }) + .then(async () => { + for (;;) { + if (!pendingSettingsRef.current.hasPending) { + return; + } + + const pendingSettings = pendingSettingsRef.current.settings; + const staleAssetIds = [...pendingSettingsRef.current.staleManagedAssetIds]; + pendingSettingsRef.current.hasPending = false; + pendingSettingsRef.current.staleManagedAssetIds.clear(); + + try { + await api.config.updateEventSoundSettings({ eventSoundSettings: pendingSettings }); + + const deleteAsset = api.eventSounds?.deleteAsset; + if (deleteAsset) { + const deletePromises = staleAssetIds.map((assetId) => deleteAsset({ assetId })); + await Promise.allSettled(deletePromises); + } + } catch { + for (const staleAssetId of staleAssetIds) { + pendingSettingsRef.current.staleManagedAssetIds.add(staleAssetId); + } + // Best-effort only. + } + } + }); + }; + + const applySettingsUpdate = (updater: (prev: EventSoundSettings) => EventSoundSettings) => { + // If the user changed a value while initial load is in flight, keep local edits authoritative. + loadNonceRef.current++; + + setEventSoundSettings((prev) => { + const next = updater(prev); + const staleManagedAssetIds = getUnreferencedManagedAssetIds(prev, next); + queueSettingsSave(next, staleManagedAssetIds); + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.EVENT_SOUND_SETTINGS_CHANGED, { + eventSoundSettings: next, + }) + ); + return next; + }); + }; + + const handleEnabledChange = (key: EventSoundKey, enabled: boolean) => { + applySettingsUpdate((prev) => { + const current = getEventSoundConfig(prev, key); + return updateEventSoundConfig(prev, key, { + ...current, + enabled, + }); + }); + }; + + const handleBrowse = async (key: EventSoundKey) => { + if (!api?.projects?.pickAudioFile || !api?.eventSounds?.importFromLocalPath) { + return; + } + + try { + const result = await api.projects.pickAudioFile({}); + if (result.filePath == null) { + return; + } + + const importedAsset = await api.eventSounds.importFromLocalPath({ + localPath: result.filePath, + }); + + applySettingsUpdate((prev) => { + const current = getEventSoundConfig(prev, key); + return updateEventSoundConfig(prev, key, { + ...current, + source: { + kind: "managed", + assetId: importedAsset.assetId, + label: importedAsset.originalName, + }, + }); + }); + } catch { + // Best-effort only. + } + }; + + const handleUpload = async (key: EventSoundKey, file: File) => { + if (!api?.eventSounds?.uploadAsset) { + return; + } + + if (file.size > MAX_AUDIO_FILE_SIZE_BYTES) { + return; + } + + try { + const base64 = await readFileAsBase64(file); + const uploadedAsset = await api.eventSounds.uploadAsset({ + base64, + originalName: file.name, + mimeType: file.type, + }); + + applySettingsUpdate((prev) => { + const current = getEventSoundConfig(prev, key); + return updateEventSoundConfig(prev, key, { + ...current, + source: { + kind: "managed", + assetId: uploadedAsset.assetId, + label: uploadedAsset.originalName, + }, + }); + }); + } catch { + // Best-effort only. + } + }; + + const openUploadPicker = (key: EventSoundKey) => { + uploadTargetKeyRef.current = key; + uploadInputRef.current?.click(); + }; + + const handleClearFile = (key: EventSoundKey) => { + applySettingsUpdate((prev) => { + const current = getEventSoundConfig(prev, key); + return updateEventSoundConfig(prev, key, { + ...current, + source: null, + }); + }); + }; + + const canPersist = Boolean(api?.config?.updateEventSoundSettings); + const canUseDesktopBrowse = + isDesktopMode() && + Boolean(api?.projects?.pickAudioFile) && + Boolean(api?.eventSounds?.importFromLocalPath); + + return ( +
+
+

Event sounds

+

+ Configure optional audio alerts for key events in Mux. +

+
+ + { + const key = uploadTargetKeyRef.current; + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!key || !file) { + return; + } + + void handleUpload(key, file); + }} + /> + +
+ {EVENT_SOUND_KEYS.map((key) => { + const soundConfig = getEventSoundConfig(eventSoundSettings, key); + const fileLabel = + soundConfig.source?.label ?? soundConfig.source?.assetId ?? "No sound selected"; + + return ( +
+
+
+
{EVENT_SOUND_LABELS[key]}
+
+ {fileLabel} +
+
+ handleEnabledChange(key, checked)} + disabled={!canPersist} + aria-label={`Toggle sound for ${EVENT_SOUND_LABELS[key]}`} + /> +
+ +
+ {canUseDesktopBrowse ? ( + + ) : ( + + )} + {soundConfig.source ? ( + + ) : null} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index 370f5fe18b..6a0dfed8ce 100644 --- a/src/browser/features/Settings/SettingsPage.tsx +++ b/src/browser/features/Settings/SettingsPage.tsx @@ -16,6 +16,7 @@ import { ShieldCheck, Server, Lock, + Volume2, } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useOnboardingPause } from "@/browser/features/SplashScreens/SplashScreenProvider"; @@ -37,6 +38,7 @@ import { ExperimentsSection } from "./Sections/ExperimentsSection"; import { ServerAccessSection } from "./Sections/ServerAccessSection"; import { KeybindsSection } from "./Sections/KeybindsSection"; import { SecuritySection } from "./Sections/SecuritySection"; +import { SoundsSection } from "./Sections/SoundsSection"; import type { SettingsSection } from "./types"; const BASE_SECTIONS: SettingsSection[] = [ @@ -100,6 +102,12 @@ const BASE_SECTIONS: SettingsSection[] = [ icon: , component: RuntimesSection, }, + { + id: "sounds", + label: "Sounds", + icon: , + component: SoundsSection, + }, { id: "experiments", label: "Experiments", diff --git a/src/browser/utils/audio/eventSounds.test.ts b/src/browser/utils/audio/eventSounds.test.ts new file mode 100644 index 0000000000..6e9cfad1e3 --- /dev/null +++ b/src/browser/utils/audio/eventSounds.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; + +import type { EventSoundSettings } from "@/common/config/schemas/appConfigOnDisk"; + +import { playEventSound } from "./eventSounds"; + +const AUTH_TOKEN_STORAGE_KEY = "mux:auth-token"; + +let originalWindow: (Window & typeof globalThis) | undefined; +let originalDocument: Document | undefined; +let originalAudio: typeof Audio | undefined; +let originalLocalStorage: Storage | undefined; +let lastAudioSource: string | null = null; + +class MockAudio { + constructor(src?: string) { + lastAudioSource = src ?? null; + } + + play(): Promise { + return Promise.resolve(); + } +} + +describe("eventSounds", () => { + beforeEach(() => { + originalWindow = globalThis.window; + originalDocument = globalThis.document; + originalAudio = globalThis.Audio; + originalLocalStorage = globalThis.localStorage; + + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + Object.defineProperty(globalThis.window, "api", { + value: undefined, + writable: true, + configurable: true, + }); + globalThis.document = globalThis.window.document; + globalThis.Audio = MockAudio as unknown as typeof Audio; + globalThis.localStorage = globalThis.window.localStorage; + + lastAudioSource = null; + }); + + afterEach(() => { + globalThis.window = originalWindow!; + globalThis.document = originalDocument!; + globalThis.Audio = originalAudio!; + globalThis.localStorage = originalLocalStorage!; + }); + + test("preserves app proxy base path and forwards URL token for playback", () => { + window.location.href = "https://coder.example.com/@u/ws/apps/mux/settings?token=url-token"; + + const settings: EventSoundSettings = { + agent_review_ready: { + enabled: true, + source: { + kind: "managed", + assetId: "11111111-1111-1111-1111-111111111111.wav", + }, + }, + }; + + playEventSound(settings, "agent_review_ready"); + + expect(lastAudioSource).toBe( + "https://coder.example.com/@u/ws/apps/mux/assets/event-sounds/11111111-1111-1111-1111-111111111111.wav?token=url-token" + ); + }); + + test("uses stored auth token when URL token is absent", () => { + window.location.href = "https://coder.example.com/@u/ws/apps/mux/settings"; + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, "stored-token"); + + const settings: EventSoundSettings = { + agent_review_ready: { + enabled: true, + source: { + kind: "managed", + assetId: "22222222-2222-2222-2222-222222222222.wav", + }, + }, + }; + + playEventSound(settings, "agent_review_ready"); + + expect(lastAudioSource).toBe( + "https://coder.example.com/@u/ws/apps/mux/assets/event-sounds/22222222-2222-2222-2222-222222222222.wav?token=stored-token" + ); + }); + + test("uses API server status in desktop mode to build playback URL", async () => { + window.location.href = "file:///app/index.html"; + Object.defineProperty(window, "api", { + value: {}, + writable: true, + configurable: true, + }); + + const settings: EventSoundSettings = { + agent_review_ready: { + enabled: true, + source: { + kind: "managed", + assetId: "33333333-3333-3333-3333-333333333333.wav", + }, + }, + }; + + playEventSound(settings, "agent_review_ready", { + server: { + getApiServerStatus: () => + Promise.resolve({ + running: true, + baseUrl: "http://127.0.0.1:55525", + bindHost: "127.0.0.1", + port: 55525, + networkBaseUrls: [], + token: "desktop-token", + configuredBindHost: null, + configuredPort: null, + configuredServeWebUi: false, + }), + }, + } as unknown as Parameters[2]); + + await Promise.resolve(); + await Promise.resolve(); + + expect(lastAudioSource).toBe( + "http://127.0.0.1:55525/assets/event-sounds/33333333-3333-3333-3333-333333333333.wav?token=desktop-token" + ); + }); +}); diff --git a/src/browser/utils/audio/eventSounds.ts b/src/browser/utils/audio/eventSounds.ts new file mode 100644 index 0000000000..8c0634435e --- /dev/null +++ b/src/browser/utils/audio/eventSounds.ts @@ -0,0 +1,90 @@ +import type { APIClient } from "@/browser/contexts/API"; +import { getStoredAuthToken } from "@/browser/components/AuthTokenModal/AuthTokenModal"; +import { getBrowserBackendBaseUrl } from "@/browser/utils/backendBaseUrl"; +import type { EventSoundSettings } from "@/common/config/schemas/appConfigOnDisk"; +import type { EventSoundKey } from "@/common/config/eventSoundTypes"; + +function getServerAuthToken(): string | null { + const urlToken = new URLSearchParams(window.location.search).get("token")?.trim(); + return urlToken?.length ? urlToken : getStoredAuthToken(); +} + +function toManagedPlaybackPath(baseUrl: string, assetId: string, authToken: string | null): string { + const playbackUrl = new URL(`assets/event-sounds/${encodeURIComponent(assetId)}`, `${baseUrl}/`); + + //