Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
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
1 change: 1 addition & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,6 +202,7 @@ container
dockBounceNotifications: s.dockBounceNotifications,
completionSound: s.completionSound,
completionVolume: s.completionVolume,
scaleSoundWithTaskLength: s.scaleSoundWithTaskLength,
customSounds: s.customSounds,
};
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,7 @@ describe("cloud task update notifications", () => {
"Cloud Task",
"end_turn",
TASK_ID,
undefined,
);
expect(harness.markActivity).toHaveBeenCalledTimes(1);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1550,6 +1550,9 @@ export class SessionService {
session.taskTitle,
"end_turn",
session.taskId,
session.promptStartedAt
? acpMsg.ts - session.promptStartedAt
: undefined,
);
}
this.d.taskViewedApi.markActivity(session.taskId);
Expand DownExpand Up@@ -1671,6 +1674,9 @@ export class SessionService {
session.taskTitle,
stopReason,
session.taskId,
session.promptStartedAt
? acpMsg.ts - session.promptStartedAt
: undefined,
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/features/notifications/identifiers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ export interface NotificationSettings {
dockBounceNotifications: boolean;
completionSound: CompletionSound;
completionVolume: number;
scaleSoundWithTaskLength: boolean;
customSounds: CustomSound[];
}

Expand Down
19 changes: 19 additions & 0 deletions packages/ui/src/features/notifications/notifications.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,7 @@ function makeBus(overrides?: {
dockBounceNotifications: true,
completionSound: "meep",
completionVolume: 80,
scaleSoundWithTaskLength: false,
customSounds: [],
...overrides?.settings,
};
Expand DownExpand Up@@ -183,4 +184,22 @@ describe("sound", () => {
bus.notifyPromptComplete("My task", "end_turn", TASK_ID);
expect(play).toHaveBeenCalledTimes(1);
});

it.each([
[
"scaling off, with duration",
false,
(10 * 60 * 1000) as number | undefined,
1,
],
["scaling on, quick task (<30s) → 3×", true, 10 * 1000, 3],
["scaling on, no duration → 1×", true, undefined, 1],
])("%s", (_label, scaleSoundWithTaskLength, durationMs, expectedRate) => {
const { bus, play } = makeBus({
hasFocus: false,
settings: { scaleSoundWithTaskLength },
});
bus.notifyPromptComplete("My task", "end_turn", TASK_ID, durationMs);
expect(play).toHaveBeenCalledWith("meep", 80, [], expectedRate);
});
});
17 changes: 16 additions & 1 deletion packages/ui/src/features/notifications/notifications.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,11 @@ import {
} from "@posthog/platform/notifications";
import { toast } from "@posthog/ui/primitives/toast";
import { openNotificationTarget } from "@posthog/ui/router/navigationBridge";
import { playCompletionSound, resolveSoundUrl } from "@posthog/ui/utils/sounds";
import {
playbackRateForTaskDuration,
playCompletionSound,
resolveSoundUrl,
} from "@posthog/ui/utils/sounds";
import { inject, injectable } from "inversify";
import {
ACTIVE_VIEW_PROVIDER,
Expand DownExpand Up@@ -34,6 +38,9 @@ export interface NotificationDescriptor {
duration?: number;
};
silent?: boolean;
// How long the task took, in ms. When the user enables sound scaling, this
// drives the completion sound's playback rate (fast task -> faster/higher).
soundDurationMs?: number;
}

// The single channel every app notification flows through. Reads focus + the
Expand DownExpand Up@@ -61,12 +68,18 @@ export class NotificationBus {
if (channel === "suppress") return;

const settings = this.settings.get();
const playbackRate =
settings.scaleSoundWithTaskLength &&
descriptor.soundDurationMs !== undefined
? playbackRateForTaskDuration(descriptor.soundDurationMs)
: 1;
// Sound fires on both delivered tiers (toast + native), not on suppress —
// matching the pre-bus behavior where any non-suppressed notification rang.
playCompletionSound(
settings.completionSound,
settings.completionVolume,
settings.customSounds,
playbackRate,
);

if (channel === "toast") {
Expand DownExpand Up@@ -100,12 +113,14 @@ export class NotificationBus {
taskTitle: string,
stopReason: string,
taskId?: string,
durationMs?: number,
): void {
if (stopReason !== "end_turn") return;
this.notify({
body: `"${this.truncateTitle(taskTitle)}" finished`,
target: taskId ? { kind: "task", taskId } : undefined,
toast: { level: "success" },
soundDurationMs: durationMs,
});
}

Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/features/sessions/sessionServiceHost.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,12 @@ function buildSessionServiceDeps(): SessionServiceDeps {
taskTitle,
taskId,
),
notifyPromptComplete: (taskTitle, stopReason, taskId) =>
notifyPromptComplete: (taskTitle, stopReason, taskId, durationMs) =>
resolveService(NotificationBus).notifyPromptComplete(
taskTitle,
stopReason,
taskId,
durationMs,
),
getIsOnline,
fetchAuthState,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,12 +40,14 @@ export function NotificationsSettings() {
dockBounceNotifications,
completionSound,
completionVolume,
scaleSoundWithTaskLength,
customSounds,
setDesktopNotifications,
setDockBadgeNotifications,
setDockBounceNotifications,
setCompletionSound,
setCompletionVolume,
setScaleSoundWithTaskLength,
removeCustomSound,
renameCustomSound,
} = useSettingsStore();
Expand DownExpand Up@@ -117,19 +119,33 @@ export function NotificationsSettings() {
[completionSound, setCompletionSound],
);

const handleScaleSoundChange = useCallback(
(checked: boolean) => {
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
setting_name: "scale_sound_with_task_length",
new_value: checked,
old_value: scaleSoundWithTaskLength,
});
setScaleSoundWithTaskLength(checked);
},
[scaleSoundWithTaskLength, setScaleSoundWithTaskLength],
);

const resetToDefaults = useCallback(() => {
setDesktopNotifications(NOTIFICATION_DEFAULTS.desktopNotifications);
setDockBadgeNotifications(NOTIFICATION_DEFAULTS.dockBadgeNotifications);
setDockBounceNotifications(NOTIFICATION_DEFAULTS.dockBounceNotifications);
setCompletionSound(NOTIFICATION_DEFAULTS.completionSound);
setCompletionVolume(NOTIFICATION_DEFAULTS.completionVolume);
setScaleSoundWithTaskLength(NOTIFICATION_DEFAULTS.scaleSoundWithTaskLength);
toast.success("Notification settings reset to defaults");
}, [
setDesktopNotifications,
setDockBadgeNotifications,
setDockBounceNotifications,
setCompletionSound,
setCompletionVolume,
setScaleSoundWithTaskLength,
]);

return (
Expand DownExpand Up@@ -270,7 +286,7 @@ export function NotificationsSettings() {
/>

{completionSound !== "none" && (
<SettingRow label="Sound volume" noBorder>
<SettingRow label="Sound volume">
<Flex align="center" gap="3">
<Slider
value={[completionVolume]}
Expand All@@ -288,6 +304,20 @@ export function NotificationsSettings() {
</SettingRow>
)}

{completionSound !== "none" && (
<SettingRow
label="Scale sound speed with task length"
description="Play the sound faster for quick tasks and slower for long ones"
noBorder
>
<Switch
checked={scaleSoundWithTaskLength}
onCheckedChange={handleScaleSoundChange}
size="1"
/>
</SettingRow>
)}

<NotificationTestHarness
bus={bus}
notifications={notifications}
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/features/settings/settingsStore.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,12 +113,14 @@ interface SettingsStore {
dockBounceNotifications: boolean;
completionSound: CompletionSound;
completionVolume: number;
scaleSoundWithTaskLength: boolean;
customSounds: CustomSound[];
setDesktopNotifications: (enabled: boolean) => void;
setDockBadgeNotifications: (enabled: boolean) => void;
setDockBounceNotifications: (enabled: boolean) => void;
setCompletionSound: (sound: CompletionSound) => void;
setCompletionVolume: (volume: number) => void;
setScaleSoundWithTaskLength: (enabled: boolean) => void;
addCustomSound: (sound: CustomSound) => void;
removeCustomSound: (id: string) => void;
renameCustomSound: (id: string, name: string) => void;
Expand DownExpand Up@@ -187,6 +189,7 @@ export const NOTIFICATION_DEFAULTS = {
dockBounceNotifications: false,
completionSound: "none" as CompletionSound,
completionVolume: 80,
scaleSoundWithTaskLength: false,
};

export const useSettingsStore = create<SettingsStore>()(
Expand DownExpand Up@@ -253,6 +256,8 @@ export const useSettingsStore = create<SettingsStore>()(
set({ dockBounceNotifications: enabled }),
setCompletionSound: (sound) => set({ completionSound: sound }),
setCompletionVolume: (volume) => set({ completionVolume: volume }),
setScaleSoundWithTaskLength: (enabled) =>
set({ scaleSoundWithTaskLength: enabled }),
addCustomSound: (sound) =>
set((state) => ({ customSounds: [...state.customSounds, sound] })),
removeCustomSound: (id) =>
Expand DownExpand Up@@ -381,6 +386,7 @@ export const useSettingsStore = create<SettingsStore>()(
dockBounceNotifications: state.dockBounceNotifications,
completionSound: state.completionSound,
completionVolume: state.completionVolume,
scaleSoundWithTaskLength: state.scaleSoundWithTaskLength,
customSounds: state.customSounds,

// Composer / chat
Expand Down
22 changes: 21 additions & 1 deletion packages/ui/src/utils/sounds.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import type {
CustomSound,
} from "@posthog/ui/features/settings/settingsStore";
import { describe, expect, it } from "vitest";
import { resolveSoundUrl } from "./sounds";
import { playbackRateForTaskDuration, resolveSoundUrl } from "./sounds";

const customs: CustomSound[] = [
{
Expand DownExpand Up@@ -40,3 +40,23 @@ describe("resolveSoundUrl", () => {
expect(resolveSoundUrl("custom:gone", customs)).toBeNull();
});
});

describe("playbackRateForTaskDuration", () => {
it.each([
["below the fast floor (10s)", 10 * 1000, 3],
["at the fast floor (30s)", 30 * 1000, 3],
["geometric mid of the fast ramp (60s)", 60 * 1000, Math.sqrt(3)],
["normal band start (2min)", 2 * 60 * 1000, 1],
["normal band end (4min)", 4 * 60 * 1000, 1],
[
"geometric mid of the slow ramp",
Math.sqrt(4 * 60 * 1000 * (30 * 60 * 1000)),
Math.sqrt(1 / 3),
],
["at the slow ceiling (30min)", 30 * 60 * 1000, 1 / 3],
["beyond the slow ceiling (2h)", 2 * 60 * 60 * 1000, 1 / 3],
["NaN (non-finite) → fast rate", Number.NaN, 3],
])("%s → %f", (_label, durationMs, expected) => {
expect(playbackRateForTaskDuration(durationMs)).toBeCloseTo(expected, 5);
});
});
31 changes: 31 additions & 0 deletions packages/ui/src/utils/sounds.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,35 @@ const SOUND_URLS: Record<Exclude<BuiltInCompletionSound, "none">, string> = {
icq: icqUrl,
};

const MIN_RATE = 1 / 3;
const MAX_RATE = 3;
const FAST_MS = 30 * 1000;
const NORMAL_START_MS = 2 * 60 * 1000;
const NORMAL_END_MS = 4 * 60 * 1000;
const SLOW_MS = 30 * 60 * 1000;

// Maps a task's duration to an audio playback rate so a quick task rings fast
// (and high-pitched) while a long one drags slow (and low). Anchored at: <=30s
// -> 3x, the 2-4min "normal" band -> 1x, >=30min -> 1/3x, with smooth
// log-interpolation across the two ramps so the rate doesn't jump at the edges.
export function playbackRateForTaskDuration(durationMs: number): number {
if (!Number.isFinite(durationMs) || durationMs <= FAST_MS) return MAX_RATE;
if (durationMs >= SLOW_MS) return MIN_RATE;
if (durationMs >= NORMAL_START_MS && durationMs <= NORMAL_END_MS) return 1;

if (durationMs < NORMAL_START_MS) {
const frac =
(Math.log(durationMs) - Math.log(FAST_MS)) /
(Math.log(NORMAL_START_MS) - Math.log(FAST_MS));
return MAX_RATE ** (1 - frac);
}

const frac =
(Math.log(durationMs) - Math.log(NORMAL_END_MS)) /
(Math.log(SLOW_MS) - Math.log(NORMAL_END_MS));
return MIN_RATE ** frac;
}

let currentAudio: HTMLAudioElement | null = null;

// Resolves the playable URL for a completion sound: a bundled asset URL for the
Expand All@@ -59,6 +88,7 @@ export function playCompletionSound(
sound: CompletionSound,
volume = 80,
customSounds: CustomSound[] = [],
playbackRate = 1,
): void {
const url = resolveSoundUrl(sound, customSounds);
if (!url) return;
Expand All@@ -70,6 +100,7 @@ export function playCompletionSound(

const audio = new Audio(url);
audio.volume = Math.max(0, Math.min(100, volume)) / 100;
audio.playbackRate = playbackRate;
currentAudio = audio;
audio.play().catch(() => {
// Audio play can fail if user hasn't interacted with the page yet
Expand Down
Loading