diff --git a/packages/ui/src/features/git-interaction/useGitInteraction.ts b/packages/ui/src/features/git-interaction/useGitInteraction.ts
index d54c5b3a9b..0620f6d254 100644
--- a/packages/ui/src/features/git-interaction/useGitInteraction.ts
+++ b/packages/ui/src/features/git-interaction/useGitInteraction.ts
@@ -11,7 +11,9 @@ import {
import { useService } from "@posthog/di/react";
import { useHostTRPC } from "@posthog/host-router/react";
import type { ChangedFile } from "@posthog/shared/domain-types";
+import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
+import { playCompletionSound } from "@posthog/ui/utils/sounds";
import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers";
@@ -330,6 +332,11 @@ export function useGitInteraction(
updateGitCacheFromSnapshot(queryClient, repoPath, result.snapshot);
}
modal.setPushState("success");
+ // Drop the user's producer tag now that code has landed on origin.
+ // playCompletionSound no-ops when the tag is set to "none".
+ const { producerTagSound, producerTagVolume, customSounds } =
+ useSettingsStore.getState();
+ playCompletionSound(producerTagSound, producerTagVolume, customSounds);
};
const runPush = async (mode?: PushMode) => {
diff --git a/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx b/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx
new file mode 100644
index 0000000000..644f164547
--- /dev/null
+++ b/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx
@@ -0,0 +1,91 @@
+import { Plus } from "@phosphor-icons/react";
+import { SettingRow } from "@posthog/ui/features/settings/SettingRow";
+import { AddCustomSoundDialog } from "@posthog/ui/features/settings/sections/AddCustomSoundDialog";
+import { ProducerTagSection } from "@posthog/ui/features/settings/sections/NotificationsSettings";
+import {
+ type CompletionSound,
+ useSettingsStore,
+} from "@posthog/ui/features/settings/settingsStore";
+import { Button, Flex } from "@radix-ui/themes";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useEffect, useState } from "react";
+
+// A focused stand-in for the sound-related rows of the Notifications settings
+// page, wired to the real settings store, the real Add-custom-sound dialog and
+// the real ProducerTagSection — enough to demo importing a tag and playing it
+// without pulling in the full page's service-backed hooks (tasks, host
+// capabilities, notification bus).
+function ProducerTagDemo() {
+ const customSounds = useSettingsStore((s) => s.customSounds);
+ const producerTagSound = useSettingsStore((s) => s.producerTagSound);
+ const producerTagVolume = useSettingsStore((s) => s.producerTagVolume);
+ const setProducerTagSound = useSettingsStore((s) => s.setProducerTagSound);
+ const setProducerTagVolume = useSettingsStore((s) => s.setProducerTagVolume);
+ const [addOpen, setAddOpen] = useState(false);
+
+ // Start each mount from a clean slate so the demo is deterministic.
+ useEffect(() => {
+ useSettingsStore.setState({
+ customSounds: [],
+ completionSound: "none",
+ producerTagSound: "none",
+ producerTagVolume: 80,
+ });
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ setProducerTagSound(value)}
+ onVolumeChange={setProducerTagVolume}
+ onAddSound={() => setAddOpen(true)}
+ />
+
+ );
+}
+
+const meta: Meta = {
+ title: "Settings/ProducerTag",
+ component: ProducerTagDemo,
+ // This story is a manual demo/record surface, not a visual-regression target:
+ // its imported clip and play button don't need a signed baseline. The runner
+ // (apps/code/.storybook/test-runner.ts) skips stories tagged "test-skip", so
+ // it's excluded from snapshot capture and won't add unapproved VR snapshots.
+ tags: ["test-skip"],
+ // Match the settings dialog's content column so rows size realistically.
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+/**
+ * Import a sound as your producer tag, then play it — the drop that fires when
+ * you push code to origin.
+ */
+export const Default: Story = {};
diff --git a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx
index 2d7835a8cb..e7c37a176e 100644
--- a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx
+++ b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx
@@ -55,6 +55,8 @@ export function NotificationsSettings() {
completionVolume,
scaleSoundWithTaskLength,
customSounds,
+ producerTagSound,
+ producerTagVolume,
setDesktopNotifications,
setDockBadgeNotifications,
setDockBounceNotifications,
@@ -62,6 +64,8 @@ export function NotificationsSettings() {
setCompletionSound,
setCompletionVolume,
setScaleSoundWithTaskLength,
+ setProducerTagSound,
+ setProducerTagVolume,
removeCustomSound,
renameCustomSound,
} = useSettingsStore();
@@ -153,6 +157,22 @@ export function NotificationsSettings() {
[completionSound, setCompletionSound],
);
+ const handleProducerTagSoundChange = useCallback(
+ (value: CompletionSound) => {
+ // Don't leak generated custom-sound ids into analytics.
+ const analyticsValue = value.startsWith("custom:") ? "custom" : value;
+ track(ANALYTICS_EVENTS.SETTING_CHANGED, {
+ setting_name: "producer_tag_sound",
+ new_value: analyticsValue,
+ old_value: producerTagSound.startsWith("custom:")
+ ? "custom"
+ : producerTagSound,
+ });
+ setProducerTagSound(value);
+ },
+ [producerTagSound, setProducerTagSound],
+ );
+
const handleScaleSoundChange = useCallback(
(checked: boolean) => {
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
@@ -173,6 +193,8 @@ export function NotificationsSettings() {
setCompletionSound(NOTIFICATION_DEFAULTS.completionSound);
setCompletionVolume(NOTIFICATION_DEFAULTS.completionVolume);
setScaleSoundWithTaskLength(NOTIFICATION_DEFAULTS.scaleSoundWithTaskLength);
+ setProducerTagSound(NOTIFICATION_DEFAULTS.producerTagSound);
+ setProducerTagVolume(NOTIFICATION_DEFAULTS.producerTagVolume);
toast.success("Notification settings reset to defaults");
}, [
setDesktopNotifications,
@@ -182,6 +204,8 @@ export function NotificationsSettings() {
setCompletionSound,
setCompletionVolume,
setScaleSoundWithTaskLength,
+ setProducerTagSound,
+ setProducerTagVolume,
]);
return (
@@ -255,47 +279,11 @@ export function NotificationsSettings() {
noBorder={completionSound === "none"}
>
-
- handleCompletionSoundChange(value as CompletionSound)
- }
- size="1"
- >
-
-
- None
- Random (all)
- {customSounds.length > 0 && (
- Random (custom)
- )}
- Guitar solo
- I'm ready
- Cute noise
- Meep
- Meep (smol)
- Bubbles
- Drop
- Knock
- Ring
- Shoot
- Slide
- Switch
- Wilhelm scream
- ICQ
- MSN Messenger
- {customSounds.length > 0 && (
-
- Custom
- {customSounds.map((sound) => (
-
- {sound.name}
-
- ))}
-
- )}
-
-
+ onValueChange={handleCompletionSoundChange}
+ customSounds={customSounds}
+ />
{completionSound !== "none" && (
)}
+ setAddSoundOpen(true)}
+ />
+
{spokenNarrationEnabled && }
void;
+ customSounds: CustomSound[];
+}) {
+ return (
+ onValueChange(next as CompletionSound)}
+ size="1"
+ >
+
+
+ None
+ Random (all)
+ {customSounds.length > 0 && (
+ Random (custom)
+ )}
+ Guitar solo
+ I'm ready
+ Cute noise
+ Meep
+ Meep (smol)
+ Bubbles
+ Drop
+ Knock
+ Ring
+ Shoot
+ Slide
+ Switch
+ Wilhelm scream
+ ICQ
+ MSN Messenger
+ {customSounds.length > 0 && (
+
+ Custom
+ {customSounds.map((sound) => (
+
+ {sound.name}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// Producer tag: a signature drop that plays when you push code to origin, à la
+// a music producer's tag. Reuses the shared sound pool — import your tag under
+// "Custom sounds" above, then select it here.
+export function ProducerTagSection({
+ sound,
+ volume,
+ customSounds,
+ onSoundChange,
+ onVolumeChange,
+ onAddSound,
+}: {
+ sound: CompletionSound;
+ volume: number;
+ customSounds: CustomSound[];
+ onSoundChange: (value: CompletionSound) => void;
+ onVolumeChange: (volume: number) => void;
+ onAddSound: () => void;
+}) {
+ const enabled = sound !== "none";
+ return (
+ <>
+
+ Producer tag
+
+
+ Drop a signature sound — your producer tag — every time you push code to
+ origin. Import your tag under "Custom sounds" above, then pick it here.
+
+
+
+
+
+ {enabled && (
+
+ playCompletionSound(sound, volume, customSounds)}
+ >
+
+
+
+ )}
+ {customSounds.length === 0 && (
+
+ )}
+
+
+
+ {enabled && (
+
+
+ onVolumeChange(value)}
+ min={0}
+ max={100}
+ step={1}
+ size="1"
+ className="w-[120px]"
+ />
+
+ {volume}%
+
+
+
+ )}
+ >
+ );
+}
+
function SpeechSwitchRow({
label,
description,
diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts
index 1b302ecf8f..11bd9a5722 100644
--- a/packages/ui/src/features/settings/settingsStore.test.ts
+++ b/packages/ui/src/features/settings/settingsStore.test.ts
@@ -352,6 +352,85 @@ describe("feature settingsStore custom sounds", () => {
);
});
+describe("feature settingsStore producer tag", () => {
+ beforeEach(async () => {
+ await resetPersistenceMocks();
+ useSettingsStore.setState({
+ customSounds: [],
+ completionSound: "none",
+ producerTagSound: "none",
+ producerTagVolume: 80,
+ });
+ });
+
+ const sound = {
+ id: "abc",
+ name: "My tag",
+ dataUrl: "data:audio/webm;base64,AAAA",
+ durationMs: 1200,
+ };
+
+ it("sets the producer tag sound and volume independently", () => {
+ useSettingsStore.getState().setProducerTagSound("guitar");
+ useSettingsStore.getState().setProducerTagVolume(40);
+ expect(useSettingsStore.getState().producerTagSound).toBe("guitar");
+ expect(useSettingsStore.getState().producerTagVolume).toBe(40);
+ // The completion sound is a separate setting and stays untouched.
+ expect(useSettingsStore.getState().completionSound).toBe("none");
+ });
+
+ it.each([
+ {
+ label: "custom tag pointing at the removed clip",
+ activeSound: "custom:abc" as CompletionSound,
+ expectedSound: "none" as CompletionSound,
+ },
+ {
+ label: "built-in tag",
+ activeSound: "guitar" as CompletionSound,
+ expectedSound: "guitar" as CompletionSound,
+ },
+ {
+ label: "random-custom tag with no clips left",
+ activeSound: "random-custom" as CompletionSound,
+ expectedSound: "none" as CompletionSound,
+ },
+ ])(
+ "removing a custom sound leaves the $label as $expectedSound",
+ ({ activeSound, expectedSound }) => {
+ useSettingsStore.getState().addCustomSound(sound);
+ useSettingsStore.getState().setProducerTagSound(activeSound);
+ useSettingsStore.getState().removeCustomSound("abc");
+ expect(useSettingsStore.getState().producerTagSound).toBe(expectedSound);
+ },
+ );
+
+ it("persists the producer tag selection", async () => {
+ useSettingsStore.getState().setProducerTagSound("guitar");
+ useSettingsStore.getState().setProducerTagVolume(55);
+
+ await waitForPersistedWrite();
+
+ const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1];
+ const persisted = JSON.parse(lastCall[1]);
+ expect(persisted.state.producerTagSound).toBe("guitar");
+ expect(persisted.state.producerTagVolume).toBe(55);
+ });
+
+ it("normalizes a rehydrated random-custom tag with no clips to none", async () => {
+ getItem.mockResolvedValue(
+ JSON.stringify({
+ state: { producerTagSound: "random-custom", customSounds: [] },
+ version: 0,
+ }),
+ );
+
+ await useSettingsStore.persist.rehydrate();
+
+ expect(useSettingsStore.getState().producerTagSound).toBe("none");
+ });
+});
+
describe("getEffectiveCustomInstructions", () => {
const synced = {
path: "/home/u/.claude/CLAUDE.md",
diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts
index eab068147d..d305e092f4 100644
--- a/packages/ui/src/features/settings/settingsStore.ts
+++ b/packages/ui/src/features/settings/settingsStore.ts
@@ -151,6 +151,11 @@ interface SettingsStore {
completionVolume: number;
scaleSoundWithTaskLength: boolean;
customSounds: CustomSound[];
+ // Producer tag: a signature sound that plays when you push code to origin
+ // (push/sync/publish). "none" turns it off. It draws from the same built-in
+ // and custom-sound pool as the completion sound.
+ producerTagSound: CompletionSound;
+ producerTagVolume: number;
setDesktopNotifications: (enabled: boolean) => void;
setDockBadgeNotifications: (enabled: boolean) => void;
setDockBounceNotifications: (enabled: boolean) => void;
@@ -161,6 +166,8 @@ interface SettingsStore {
addCustomSound: (sound: CustomSound) => void;
removeCustomSound: (id: string) => void;
renameCustomSound: (id: string, name: string) => void;
+ setProducerTagSound: (sound: CompletionSound) => void;
+ setProducerTagVolume: (volume: number) => void;
// Spoken notifications
spokenNotifications: boolean;
@@ -279,6 +286,8 @@ export const NOTIFICATION_DEFAULTS = {
completionSound: "none" as CompletionSound,
completionVolume: 80,
scaleSoundWithTaskLength: false,
+ producerTagSound: "none" as CompletionSound,
+ producerTagVolume: 80,
spokenNotifications: false,
spokenNotifyNeedsInput: true,
spokenNotifyCompletion: true,
@@ -385,15 +394,20 @@ export const useSettingsStore = create()(
removeCustomSound: (id) =>
set((state) => {
const customSounds = state.customSounds.filter((s) => s.id !== id);
- const soundNowUnplayable =
- state.completionSound === `custom:${id}` ||
- (state.completionSound === "random-custom" &&
- customSounds.length === 0);
+ // A selection is orphaned if it pointed at the removed clip, or was
+ // "random-custom" with no custom sounds left to pick from. Applies to
+ // every setting that references the shared sound pool.
+ const isOrphaned = (sound: CompletionSound) =>
+ sound === `custom:${id}` ||
+ (sound === "random-custom" && customSounds.length === 0);
return {
customSounds,
- completionSound: soundNowUnplayable
+ completionSound: isOrphaned(state.completionSound)
? "none"
: state.completionSound,
+ producerTagSound: isOrphaned(state.producerTagSound)
+ ? "none"
+ : state.producerTagSound,
};
}),
renameCustomSound: (id, name) =>
@@ -402,6 +416,8 @@ export const useSettingsStore = create()(
s.id === id ? { ...s, name } : s,
),
})),
+ setProducerTagSound: (sound) => set({ producerTagSound: sound }),
+ setProducerTagVolume: (volume) => set({ producerTagVolume: volume }),
// Composer / chat
autoConvertLongText: "2500",
@@ -543,6 +559,8 @@ export const useSettingsStore = create()(
completionVolume: state.completionVolume,
scaleSoundWithTaskLength: state.scaleSoundWithTaskLength,
customSounds: state.customSounds,
+ producerTagSound: state.producerTagSound,
+ producerTagVolume: state.producerTagVolume,
spokenNotifications: state.spokenNotifications,
spokenNotifyNeedsInput: state.spokenNotifyNeedsInput,
spokenNotifyCompletion: state.spokenNotifyCompletion,
@@ -613,6 +631,12 @@ export const useSettingsStore = create()(
) {
(merged as Record).completionSound = "none";
}
+ if (
+ merged.producerTagSound === "random-custom" &&
+ (!merged.customSounds || merged.customSounds.length === 0)
+ ) {
+ (merged as Record).producerTagSound = "none";
+ }
return merged;
},
},