From 9aaa4c62fb54daf9b8ad36b954b76cc97a4e78f7 Mon Sep 17 00:00:00 2001 From: Sean Mallia Date: Tue, 21 Jul 2026 09:43:58 -0400 Subject: [PATCH 1/3] feat(git): play a producer tag sound when pushing to origin Add a "Producer tag" setting under Notifications: pick a signature sound (built-in or an imported custom sound) that drops on a successful push, sync, or publish to the remote. - New producerTagSound/producerTagVolume settings, persisted and reset with the other notification defaults; orphaned custom-sound references are cleaned up alongside completionSound. - Trigger fires from the single push-success path in useGitInteraction, reusing the existing playCompletionSound helper (no-ops when "none"). - Extract a shared SoundSelect so the tag picker reuses the same pool as the completion sound. Generated-By: PostHog Code Task-Id: 70d325b0-91ae-442c-bbe2-63a84000bfdb --- .../git-interaction/useGitInteraction.ts | 7 + .../sections/NotificationsSettings.tsx | 213 ++++++++++++++---- .../features/settings/settingsStore.test.ts | 79 +++++++ .../ui/src/features/settings/settingsStore.ts | 34 ++- 4 files changed, 288 insertions(+), 45 deletions(-) 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.tsx b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx index 2d7835a8cb..1528a5fec0 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. +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; }, }, From 86414be2d361e3fde1c6ff076731a54dae46dedd Mon Sep 17 00:00:00 2001 From: Sean Mallia Date: Tue, 21 Jul 2026 10:37:55 -0400 Subject: [PATCH 2/3] test(ui): add Storybook story for the producer tag settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a focused Storybook story that renders the real ProducerTagSection and Add-custom-sound dialog against the settings store, so the import-a-sound → select-as-tag → play flow can be demoed and visually reviewed without the authenticated app. Exports ProducerTagSection so the story can render it. Generated-By: PostHog Code Task-Id: 70d325b0-91ae-442c-bbe2-63a84000bfdb --- .../NotificationsSettings.stories.tsx | 86 +++++++++++++++++++ .../sections/NotificationsSettings.tsx | 2 +- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx 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..b613d41e6f --- /dev/null +++ b/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx @@ -0,0 +1,86 @@ +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, + // 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 1528a5fec0..e7c37a176e 100644 --- a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx +++ b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx @@ -450,7 +450,7 @@ function SoundSelect({ // 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. -function ProducerTagSection({ +export function ProducerTagSection({ sound, volume, customSounds, From f8b99b849c045291dfa398d9c2c2d91cd30ff02f Mon Sep 17 00:00:00 2001 From: Sean Mallia Date: Tue, 21 Jul 2026 10:56:33 -0400 Subject: [PATCH 3/3] test(ui): exclude producer tag story from visual regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Settings/ProducerTag story added 2 new visual snapshots, which fails the Visual Review check until a human approves them. It's a manual demo/record surface that doesn't need a signed baseline, so tag it "test-skip" — the Storybook test-runner skips it from snapshot capture. The story stays visible in Storybook for manual use. Generated-By: PostHog Code Task-Id: 70d325b0-91ae-442c-bbe2-63a84000bfdb --- .../settings/sections/NotificationsSettings.stories.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx b/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx index b613d41e6f..644f164547 100644 --- a/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx +++ b/packages/ui/src/features/settings/sections/NotificationsSettings.stories.tsx @@ -66,6 +66,11 @@ function ProducerTagDemo() { 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) => (