From da9a1cfb68aad62a1c87d4afb7e27d1db1c8571f Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Fri, 19 Jun 2026 18:00:02 +0100 Subject: [PATCH 1/2] feat(notifications): let users record a custom completion sound Add a "Custom (record)" option to the notification sound picker so users can record their own sound (up to 5s) from the microphone in-app via MediaRecorder. The recording is stored as a data URL in the persisted settings store and played when the agent finishes a task or needs input. Adds the macOS microphone entitlement and NSMicrophoneUsageDescription so recording works in the packaged, sandboxed app. Generated-By: PostHog Code Task-Id: 1d32a904-f0b9-4439-9900-d24d12be04a8 --- apps/code/build/entitlements.mac.plist | 4 + apps/code/forge.config.ts | 10 +- apps/code/src/renderer/desktop-services.ts | 1 + .../src/features/notifications/identifiers.ts | 1 + .../notifications/notifications.test.ts | 31 +++++ .../features/notifications/notifications.ts | 13 +- .../settings/sections/CustomSoundRecorder.tsx | 111 ++++++++++++++++++ .../settings/sections/GeneralSettings.tsx | 27 ++++- .../ui/src/features/settings/settingsStore.ts | 10 +- packages/ui/src/utils/sounds.ts | 13 +- 10 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx 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..cf118dc973 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,36 @@ describe("TaskNotificationService", () => { ); }); + it("plays a recorded custom sound and silences the OS notification", () => { + const { service, notify, play } = makeService({ + hasFocus: false, + settings: { + completionSound: "custom", + customCompletionSound: "data:audio/webm;base64,AAAA", + }, + }); + service.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(play).toHaveBeenCalledWith( + "custom", + 80, + "data:audio/webm;base64,AAAA", + ); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: true }), + ); + }); + + it("is not silent when custom is selected but nothing was recorded", () => { + const { service, notify } = makeService({ + hasFocus: false, + settings: { completionSound: "custom", customCompletionSound: null }, + }); + service.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: false }), + ); + }); + 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..d62690710d --- /dev/null +++ b/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx @@ -0,0 +1,111 @@ +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.onloadend = () => { + setCustomCompletionSound( + typeof reader.result === "string" ? reader.result : null, + ); + }; + 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..0381a660c3 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,6 +404,16 @@ export function GeneralSettings() { + {completionSound === "custom" && ( + + + + )} + {completionSound !== "none" && ( diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index cfdf5c8757..81ac759001 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -42,7 +42,8 @@ export type CompletionSound = | "slide" | "switch" | "wilhelm" - | "icq"; + | "icq" + | "custom"; export type TerminalFont = | "berkeley-mono" @@ -100,11 +101,14 @@ interface SettingsStore { dockBounceNotifications: boolean; completionSound: CompletionSound; completionVolume: number; + /** Data URL of a user-recorded sound, played when completionSound is "custom". */ + customCompletionSound: string | null; setDesktopNotifications: (enabled: boolean) => 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) { From 44903ce9736199f3717ac2c8d0238db061544b07 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Fri, 19 Jun 2026 18:21:48 +0100 Subject: [PATCH 2/2] fix(notifications): address review feedback on custom sound - Surface a toast when reading the recording fails instead of silently clearing it (split FileReader onloadend into onload/onerror). - Hide the volume slider when "custom" is selected but nothing is recorded yet, mirroring the Test button's canTestSound gating. - Parameterize the two custom-sound notification tests into a single it.each block. Generated-By: PostHog Code Task-Id: 1d32a904-f0b9-4439-9900-d24d12be04a8 --- .../notifications/notifications.test.ts | 37 ++++++++----------- .../settings/sections/CustomSoundRecorder.tsx | 13 +++++-- .../settings/sections/GeneralSettings.tsx | 2 +- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index cf118dc973..3f88d1abdc 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -156,33 +156,26 @@ describe("TaskNotificationService", () => { ); }); - it("plays a recorded custom sound and silences the OS notification", () => { + 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: "data:audio/webm;base64,AAAA", - }, - }); - service.notifyPromptComplete("My task", "end_turn", TASK_ID); - expect(play).toHaveBeenCalledWith( - "custom", - 80, - "data:audio/webm;base64,AAAA", - ); - expect(notify).toHaveBeenCalledWith( - expect.objectContaining({ silent: true }), - ); - }); - - it("is not silent when custom is selected but nothing was recorded", () => { - const { service, notify } = makeService({ - hasFocus: false, - settings: { completionSound: "custom", customCompletionSound: null }, + settings: { completionSound: "custom", customCompletionSound }, }); service.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(play).toHaveBeenCalledWith("custom", 80, customCompletionSound); expect(notify).toHaveBeenCalledWith( - expect.objectContaining({ silent: false }), + expect.objectContaining({ silent: expectedSilent }), ); }); diff --git a/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx b/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx index d62690710d..3711e40c46 100644 --- a/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx +++ b/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx @@ -53,10 +53,15 @@ export function CustomSoundRecorder() { type: recorder.mimeType || "audio/webm", }); const reader = new FileReader(); - reader.onloadend = () => { - setCustomCompletionSound( - typeof reader.result === "string" ? reader.result : null, - ); + 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); }; diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 0381a660c3..0bcc6ddd43 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -414,7 +414,7 @@ export function GeneralSettings() { )} - {completionSound !== "none" && ( + {canTestSound && (