diff --git a/apps/code/build/entitlements.mac.plist b/apps/code/build/entitlements.mac.plist
index 0d3832ce4d..75dd72a95f 100644
--- a/apps/code/build/entitlements.mac.plist
+++ b/apps/code/build/entitlements.mac.plist
@@ -13,5 +13,9 @@
com.apple.security.cs.allow-jit
+
+
+ com.apple.security.device.audio-input
+
diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts
index b88e085c80..66ed49f658 100644
--- a/apps/code/forge.config.ts
+++ b/apps/code/forge.config.ts
@@ -173,11 +173,11 @@ const config: ForgeConfig = {
extraResource: hasAssetsCar
? ["build/Assets.car", "build/app-icon.png"]
: ["build/app-icon.png"],
- extendInfo: hasAssetsCar
- ? {
- CFBundleIconName: "Icon",
- }
- : {},
+ extendInfo: {
+ NSMicrophoneUsageDescription:
+ "PostHog Code uses your microphone to record a custom notification sound.",
+ ...(hasAssetsCar ? { CFBundleIconName: "Icon" } : {}),
+ },
...(osxSignConfig
? {
osxSign: osxSignConfig,
diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts
index a5dd914288..a018e6f17b 100644
--- a/apps/code/src/renderer/desktop-services.ts
+++ b/apps/code/src/renderer/desktop-services.ts
@@ -200,6 +200,7 @@ container
dockBounceNotifications: s.dockBounceNotifications,
completionSound: s.completionSound,
completionVolume: s.completionVolume,
+ customCompletionSound: s.customCompletionSound,
};
},
});
diff --git a/packages/ui/src/features/notifications/identifiers.ts b/packages/ui/src/features/notifications/identifiers.ts
index 3343baeedb..fc7a24b05c 100644
--- a/packages/ui/src/features/notifications/identifiers.ts
+++ b/packages/ui/src/features/notifications/identifiers.ts
@@ -6,6 +6,7 @@ export interface NotificationSettings {
dockBounceNotifications: boolean;
completionSound: CompletionSound;
completionVolume: number;
+ customCompletionSound: string | null;
}
export interface INotificationSettings {
diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts
index c4906818bd..3f88d1abdc 100644
--- a/packages/ui/src/features/notifications/notifications.test.ts
+++ b/packages/ui/src/features/notifications/notifications.test.ts
@@ -33,6 +33,7 @@ function makeService(overrides?: {
dockBounceNotifications: true,
completionSound: "meep",
completionVolume: 80,
+ customCompletionSound: null,
...overrides?.settings,
};
@@ -155,6 +156,29 @@ describe("TaskNotificationService", () => {
);
});
+ it.each([
+ {
+ name: "plays a recorded custom sound and silences the OS notification",
+ customCompletionSound: "data:audio/webm;base64,AAAA",
+ expectedSilent: true,
+ },
+ {
+ name: "is not silent when custom is selected but nothing was recorded",
+ customCompletionSound: null,
+ expectedSilent: false,
+ },
+ ])("$name", ({ customCompletionSound, expectedSilent }) => {
+ const { service, notify, play } = makeService({
+ hasFocus: false,
+ settings: { completionSound: "custom", customCompletionSound },
+ });
+ service.notifyPromptComplete("My task", "end_turn", TASK_ID);
+ expect(play).toHaveBeenCalledWith("custom", 80, customCompletionSound);
+ expect(notify).toHaveBeenCalledWith(
+ expect.objectContaining({ silent: expectedSilent }),
+ );
+ });
+
it("truncates long titles", () => {
const { service, notify } = makeService({ hasFocus: false });
const longTitle = "x".repeat(80);
diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts
index 0c862b194b..bdcc20ae64 100644
--- a/packages/ui/src/features/notifications/notifications.ts
+++ b/packages/ui/src/features/notifications/notifications.ts
@@ -44,14 +44,21 @@ export class TaskNotificationService {
if (!this.shouldNotify(taskId)) return;
const settings = this.settings.get();
- const willPlayCustomSound = settings.completionSound !== "none";
- playCompletionSound(settings.completionSound, settings.completionVolume);
+ const willPlaySound =
+ settings.completionSound !== "none" &&
+ (settings.completionSound !== "custom" ||
+ Boolean(settings.customCompletionSound));
+ playCompletionSound(
+ settings.completionSound,
+ settings.completionVolume,
+ settings.customCompletionSound,
+ );
if (settings.desktopNotifications) {
this.notifications.notify({
title: "PostHog Code",
body,
- silent: willPlayCustomSound,
+ silent: willPlaySound,
taskId,
});
}
diff --git a/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx b/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx
new file mode 100644
index 0000000000..3711e40c46
--- /dev/null
+++ b/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx
@@ -0,0 +1,116 @@
+import { Microphone, Stop, Trash } from "@phosphor-icons/react";
+import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
+import { Button, Flex, Text } from "@radix-ui/themes";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+
+// Keep recordings short so the data URL stays small enough to persist in settings.
+const MAX_RECORDING_MS = 5000;
+
+export function CustomSoundRecorder() {
+ const customCompletionSound = useSettingsStore(
+ (s) => s.customCompletionSound,
+ );
+ const setCustomCompletionSound = useSettingsStore(
+ (s) => s.setCustomCompletionSound,
+ );
+
+ const [isRecording, setIsRecording] = useState(false);
+ const recorderRef = useRef(null);
+ const stopTimeoutRef = useRef | null>(null);
+
+ const stopRecording = useCallback(() => {
+ if (stopTimeoutRef.current) {
+ clearTimeout(stopTimeoutRef.current);
+ stopTimeoutRef.current = null;
+ }
+ if (recorderRef.current?.state === "recording") {
+ recorderRef.current.stop();
+ }
+ }, []);
+
+ const startRecording = useCallback(async () => {
+ let stream: MediaStream;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ } catch {
+ toast.error("Microphone access denied", {
+ description:
+ "Allow microphone access for PostHog Code in System Settings > Privacy & Security > Microphone.",
+ });
+ return;
+ }
+
+ const chunks: Blob[] = [];
+ const recorder = new MediaRecorder(stream);
+ recorder.ondataavailable = (event) => {
+ if (event.data.size > 0) chunks.push(event.data);
+ };
+ recorder.onstop = () => {
+ for (const track of stream.getTracks()) track.stop();
+ setIsRecording(false);
+ const blob = new Blob(chunks, {
+ type: recorder.mimeType || "audio/webm",
+ });
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (typeof reader.result === "string") {
+ setCustomCompletionSound(reader.result);
+ }
+ };
+ reader.onerror = () => {
+ toast.error("Couldn't save recording", {
+ description: "Reading the recorded audio failed. Please try again.",
+ });
+ };
+ reader.readAsDataURL(blob);
+ };
+
+ recorderRef.current = recorder;
+ recorder.start();
+ setIsRecording(true);
+ stopTimeoutRef.current = setTimeout(stopRecording, MAX_RECORDING_MS);
+ }, [setCustomCompletionSound, stopRecording]);
+
+ // Stop any in-flight recording and release the mic if the view unmounts.
+ useEffect(() => {
+ return () => {
+ if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current);
+ if (recorderRef.current?.state === "recording") {
+ recorderRef.current.stop();
+ }
+ };
+ }, []);
+
+ return (
+
+ {isRecording ? (
+
+ ) : (
+
+ )}
+ {customCompletionSound && !isRecording && (
+
+ )}
+ {!customCompletionSound && !isRecording && (
+
+ No recording yet
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx
index 9702e59a72..0bcc6ddd43 100644
--- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx
+++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx
@@ -8,6 +8,7 @@ import {
type CollapseMode,
} from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig";
import { SettingRow } from "@posthog/ui/features/settings/SettingRow";
+import { CustomSoundRecorder } from "@posthog/ui/features/settings/sections/CustomSoundRecorder";
import {
type AutoConvertLongText,
type CompletionSound,
@@ -81,6 +82,7 @@ export function GeneralSettings() {
dockBounceNotifications,
completionSound,
completionVolume,
+ customCompletionSound,
autoConvertLongText,
defaultInitialTaskMode,
defaultMessagingMode,
@@ -163,8 +165,16 @@ export function GeneralSettings() {
);
const handleTestSound = useCallback(() => {
- playCompletionSound(completionSound, completionVolume);
- }, [completionSound, completionVolume]);
+ playCompletionSound(
+ completionSound,
+ completionVolume,
+ customCompletionSound,
+ );
+ }, [completionSound, completionVolume, customCompletionSound]);
+
+ const canTestSound =
+ completionSound !== "none" &&
+ (completionSound !== "custom" || Boolean(customCompletionSound));
const handleAutoConvertLongTextChange = useCallback(
(value: AutoConvertLongText) => {
@@ -383,9 +393,10 @@ export function GeneralSettings() {
Switch
Wilhelm scream
ICQ
+ Custom (record)
- {completionSound !== "none" && (
+ {canTestSound && (
@@ -393,7 +404,17 @@ export function GeneralSettings() {
- {completionSound !== "none" && (
+ {completionSound === "custom" && (
+
+
+
+ )}
+
+ {canTestSound && (
void;
setDockBadgeNotifications: (enabled: boolean) => void;
setDockBounceNotifications: (enabled: boolean) => void;
setCompletionSound: (sound: CompletionSound) => void;
setCompletionVolume: (volume: number) => void;
+ setCustomCompletionSound: (dataUrl: string | null) => void;
// Composer / chat
autoConvertLongText: AutoConvertLongText;
@@ -213,6 +217,7 @@ export const useSettingsStore = create()(
dockBounceNotifications: false,
completionSound: "none",
completionVolume: 80,
+ customCompletionSound: null,
setDesktopNotifications: (enabled) =>
set({ desktopNotifications: enabled }),
setDockBadgeNotifications: (enabled) =>
@@ -221,6 +226,8 @@ export const useSettingsStore = create()(
set({ dockBounceNotifications: enabled }),
setCompletionSound: (sound) => set({ completionSound: sound }),
setCompletionVolume: (volume) => set({ completionVolume: volume }),
+ setCustomCompletionSound: (dataUrl) =>
+ set({ customCompletionSound: dataUrl }),
// Composer / chat
autoConvertLongText: "2500",
@@ -324,6 +331,7 @@ export const useSettingsStore = create()(
dockBounceNotifications: state.dockBounceNotifications,
completionSound: state.completionSound,
completionVolume: state.completionVolume,
+ customCompletionSound: state.customCompletionSound,
// Composer / chat
autoConvertLongText: state.autoConvertLongText,
diff --git a/packages/ui/src/utils/sounds.ts b/packages/ui/src/utils/sounds.ts
index ae61f978bb..a7917a4aba 100644
--- a/packages/ui/src/utils/sounds.ts
+++ b/packages/ui/src/utils/sounds.ts
@@ -14,7 +14,10 @@ import slideUrl from "../assets/sounds/slide.mp3";
import switchUrl from "../assets/sounds/switch.mp3";
import wilhelmUrl from "../assets/sounds/wilhelm.mp3";
-const SOUND_URLS: Record, string> = {
+const SOUND_URLS: Record<
+ Exclude,
+ string
+> = {
guitar: guitarUrl,
danilo: daniloUrl,
revi: reviUrl,
@@ -33,10 +36,14 @@ const SOUND_URLS: Record, string> = {
let currentAudio: HTMLAudioElement | null = null;
-export function playCompletionSound(sound: CompletionSound, volume = 80): void {
+export function playCompletionSound(
+ sound: CompletionSound,
+ volume = 80,
+ customSoundUrl?: string | null,
+): void {
if (sound === "none") return;
- const url = SOUND_URLS[sound];
+ const url = sound === "custom" ? customSoundUrl : SOUND_URLS[sound];
if (!url) return;
if (currentAudio) {