Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b4c5146
Add event sound settings to config schema and node config
jaaydenh Mar 9, 2026
9ae83f7
🤖 feat: add pickAudioFile IPC endpoint
jaaydenh Mar 9, 2026
f578bff
feat: add sounds settings section
jaaydenh Mar 9, 2026
a428b02
Add renderer event sound playback for response completion
jaaydenh Mar 9, 2026
c2c2933
Expose event sound settings to renderer config API
jaaydenh Mar 9, 2026
b2a448e
fix: remove duplicate eventSoundSettings properties
jaaydenh Mar 9, 2026
f896109
fix: address codex event sound review feedback
jaaydenh Mar 9, 2026
585903c
fix: sync event sound settings ref after settings edits
jaaydenh Mar 9, 2026
9645697
fix: handle Windows drive paths in event sound URLs
jaaydenh Mar 9, 2026
21e93d8
fix: guard event sound config load against stale overwrite
jaaydenh Mar 9, 2026
8727df1
fix: encode special characters in file audio URLs
jaaydenh Mar 9, 2026
9b30780
feat: add managed event sound asset storage
jaaydenh Mar 10, 2026
5b2797b
fix: add browser event sound upload and source labels
jaaydenh Mar 10, 2026
7a689a8
🤖 fix: show upload button in browser event sounds settings
jaaydenh Mar 10, 2026
11ecd67
🤖 fix: make managed event sounds playable in browser auth mode
jaaydenh Mar 10, 2026
0062a79
🤖 fix: preserve app proxy path for event sound playback URLs
jaaydenh Mar 10, 2026
cd03138
🤖 fix: proxy event sound asset route in Vite dev server
jaaydenh Mar 10, 2026
4ade1fa
🤖 tests: stabilize auth-token helper mocks across bun test files
jaaydenh Mar 10, 2026
23fd76c
🤖 fix: move Sounds settings section above Experiments
jaaydenh Mar 10, 2026
12cbf7b
🤖 fix: validate indexed filenames before deleting sound assets
jaaydenh Mar 10, 2026
312305b
🤖 fix: stat local audio files before reading import payloads
jaaydenh Mar 10, 2026
f545ce1
fix: handle desktop event-sound playback URL and browse import errors
jaaydenh Mar 11, 2026
666eaa7
fix: handle stale event-sound assets and blank upload mime types
jaaydenh Mar 11, 2026
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
58 changes: 56 additions & 2 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -238,6 +240,55 @@ function AppInner() {
workspaceMetadataRef.current = workspaceMetadata;
}, [workspaceMetadata]);

const eventSoundSettingsRef = useRef<EventSoundSettings | undefined>(undefined);
const eventSoundSettingsLoadVersionRef = useRef(0);
useEffect(() => {
const handleEventSoundSettingsChanged = (
event: CustomEventType<typeof CUSTOM_EVENTS.EVENT_SOUND_SETTINGS_CHANGED>
) => {
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);
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -1038,7 +1092,7 @@ function AppInner() {
return () => {
unsubscribe?.();
};
}, [setSelectedWorkspace, workspaceStore]);
}, [api, setSelectedWorkspace, workspaceStore]);

// Show auth modal if authentication is required
if (status === "auth_required") {
Expand Down
30 changes: 25 additions & 5 deletions src/browser/components/AppLoader/AppLoader.auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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 }) => (
<div data-testid="AuthTokenModalMock">{props.error ?? "no-error"}</div>
),
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";
Expand Down
35 changes: 34 additions & 1 deletion src/browser/contexts/API.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,44 @@ let fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Respons

// Mock orpc client
let pingImpl: () => Promise<string> = () => 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", () => ({
Expand All @@ -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,
Expand Down
Loading
Loading