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
1300903
Add event sound settings to config schema and node config
jaaydenh Mar 9, 2026
10fac1f
🤖 feat: add pickAudioFile IPC endpoint
jaaydenh Mar 9, 2026
e87830b
feat: add sounds settings section
jaaydenh Mar 9, 2026
96e4991
Add renderer event sound playback for response completion
jaaydenh Mar 9, 2026
19e0ee3
Expose event sound settings to renderer config API
jaaydenh Mar 9, 2026
96a07b7
fix: remove duplicate eventSoundSettings properties
jaaydenh Mar 9, 2026
0ed7c6f
fix: address codex event sound review feedback
jaaydenh Mar 9, 2026
481ef89
fix: sync event sound settings ref after settings edits
jaaydenh Mar 9, 2026
c493fed
fix: handle Windows drive paths in event sound URLs
jaaydenh Mar 9, 2026
df36e71
fix: guard event sound config load against stale overwrite
jaaydenh Mar 9, 2026
623e38c
fix: encode special characters in file audio URLs
jaaydenh Mar 9, 2026
2506310
feat: add managed event sound asset storage
jaaydenh Mar 10, 2026
5d0f76c
fix: add browser event sound upload and source labels
jaaydenh Mar 10, 2026
2a14d0f
🤖 fix: show upload button in browser event sounds settings
jaaydenh Mar 10, 2026
a48d558
🤖 fix: make managed event sounds playable in browser auth mode
jaaydenh Mar 10, 2026
7dafd7d
🤖 fix: preserve app proxy path for event sound playback URLs
jaaydenh Mar 10, 2026
84474cd
🤖 fix: proxy event sound asset route in Vite dev server
jaaydenh Mar 10, 2026
2fadfd1
🤖 tests: stabilize auth-token helper mocks across bun test files
jaaydenh Mar 10, 2026
324d56b
🤖 fix: move Sounds settings section above Experiments
jaaydenh Mar 10, 2026
647f208
🤖 fix: validate indexed filenames before deleting sound assets
jaaydenh Mar 10, 2026
c4d0a18
🤖 fix: stat local audio files before reading import payloads
jaaydenh Mar 10, 2026
b20426b
fix: handle desktop event-sound playback URL and browse import errors
jaaydenh Mar 11, 2026
fbe1a63
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