Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions apps/code/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@
<!-- Enable JIT compilation for V8 -->
<key>com.apple.security.cs.allow-jit</key>
<true/>

<!-- Microphone access for recording a custom notification sound -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
10 changes: 5 additions & 5 deletions apps/code/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ container
dockBounceNotifications: s.dockBounceNotifications,
completionSound: s.completionSound,
completionVolume: s.completionVolume,
customCompletionSound: s.customCompletionSound,
};
},
});
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/features/notifications/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface NotificationSettings {
dockBounceNotifications: boolean;
completionSound: CompletionSound;
completionVolume: number;
customCompletionSound: string | null;
}

export interface INotificationSettings {
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/src/features/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function makeService(overrides?: {
dockBounceNotifications: true,
completionSound: "meep",
completionVolume: 80,
customCompletionSound: null,
...overrides?.settings,
};

Expand Down Expand Up @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions packages/ui/src/features/notifications/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
116 changes: 116 additions & 0 deletions packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx
Original file line number Diff line number Diff line change
@@ -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<MediaRecorder | null>(null);
const stopTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<Flex align="center" gap="2">
{isRecording ? (
<Button color="red" variant="soft" size="1" onClick={stopRecording}>
<Stop weight="fill" size={12} />
Stop
</Button>
) : (
<Button variant="soft" size="1" onClick={startRecording}>
<Microphone size={12} />
{customCompletionSound ? "Re-record" : "Record"}
</Button>
)}
{customCompletionSound && !isRecording && (
<Button
variant="ghost"
color="gray"
size="1"
onClick={() => setCustomCompletionSound(null)}
>
<Trash size={12} />
Clear
</Button>
)}
{!customCompletionSound && !isRecording && (
<Text color="gray" className="text-[13px]">
No recording yet
</Text>
)}
</Flex>
);
}
29 changes: 25 additions & 4 deletions packages/ui/src/features/settings/sections/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -81,6 +82,7 @@ export function GeneralSettings() {
dockBounceNotifications,
completionSound,
completionVolume,
customCompletionSound,
autoConvertLongText,
defaultInitialTaskMode,
defaultMessagingMode,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -383,17 +393,28 @@ export function GeneralSettings() {
<Select.Item value="switch">Switch</Select.Item>
<Select.Item value="wilhelm">Wilhelm scream</Select.Item>
<Select.Item value="icq">ICQ</Select.Item>
<Select.Item value="custom">Custom (record)</Select.Item>
</Select.Content>
</Select.Root>
{completionSound !== "none" && (
{canTestSound && (
<Button variant="soft" size="1" onClick={handleTestSound}>
Test
</Button>
)}
</Flex>
</SettingRow>

{completionSound !== "none" && (
{completionSound === "custom" && (
<SettingRow
label="Custom sound"
description="Record up to 5 seconds from your microphone to use as your notification sound"
noBorder
>
<CustomSoundRecorder />
</SettingRow>
)}

{canTestSound && (
<SettingRow label="Sound volume" noBorder>
<Flex align="center" gap="3">
<Slider
Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/features/settings/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export type CompletionSound =
| "slide"
| "switch"
| "wilhelm"
| "icq";
| "icq"
| "custom";

export type TerminalFont =
| "berkeley-mono"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -213,6 +217,7 @@ export const useSettingsStore = create<SettingsStore>()(
dockBounceNotifications: false,
completionSound: "none",
completionVolume: 80,
customCompletionSound: null,
setDesktopNotifications: (enabled) =>
set({ desktopNotifications: enabled }),
setDockBadgeNotifications: (enabled) =>
Expand All @@ -221,6 +226,8 @@ export const useSettingsStore = create<SettingsStore>()(
set({ dockBounceNotifications: enabled }),
setCompletionSound: (sound) => set({ completionSound: sound }),
setCompletionVolume: (volume) => set({ completionVolume: volume }),
setCustomCompletionSound: (dataUrl) =>
set({ customCompletionSound: dataUrl }),

// Composer / chat
autoConvertLongText: "2500",
Expand Down Expand Up @@ -324,6 +331,7 @@ export const useSettingsStore = create<SettingsStore>()(
dockBounceNotifications: state.dockBounceNotifications,
completionSound: state.completionSound,
completionVolume: state.completionVolume,
customCompletionSound: state.customCompletionSound,

// Composer / chat
autoConvertLongText: state.autoConvertLongText,
Expand Down
13 changes: 10 additions & 3 deletions packages/ui/src/utils/sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exclude<CompletionSound, "none">, string> = {
const SOUND_URLS: Record<
Exclude<CompletionSound, "none" | "custom">,
string
> = {
guitar: guitarUrl,
danilo: daniloUrl,
revi: reviUrl,
Expand All @@ -33,10 +36,14 @@ const SOUND_URLS: Record<Exclude<CompletionSound, "none">, 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) {
Expand Down
Loading