From 7b2b6ca325f4347c83e6f99f3cd6ffaeb39ebdde Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 6 Feb 2026 19:36:33 -0700 Subject: [PATCH] fix: prevent double notification sound playback in dev mode --- webview-ui/src/components/chat/ChatView.tsx | 15 ++- .../ChatView.notification-sound.spec.tsx | 107 ++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 5c377eb1f59..832ad49a1a9 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -233,9 +233,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction messages.at(-2), [messages]) const volume = typeof soundVolume === "number" ? soundVolume : 0.5 - const [playNotification] = useSound(`${audioBaseUri}/notification.wav`, { volume, soundEnabled }) - const [playCelebration] = useSound(`${audioBaseUri}/celebration.wav`, { volume, soundEnabled }) - const [playProgressLoop] = useSound(`${audioBaseUri}/progress_loop.wav`, { volume, soundEnabled }) + const [playNotification] = useSound(`${audioBaseUri}/notification.wav`, { volume, soundEnabled, interrupt: true }) + const [playCelebration] = useSound(`${audioBaseUri}/celebration.wav`, { volume, soundEnabled, interrupt: true }) + const [playProgressLoop] = useSound(`${audioBaseUri}/progress_loop.wav`, { volume, soundEnabled, interrupt: true }) + + const lastPlayedRef = useRef>({}) const playSound = useCallback( (audioType: AudioType) => { @@ -243,6 +245,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction { ) }) }) + +describe("ChatView - Sound Debounce", () => { + beforeEach(() => vi.clearAllMocks()) + + it("should not play the same sound type twice within 100ms", async () => { + const now = 1_000_000 + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now) + + renderChatView() + + // Hydrate with initial task + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [{ type: "say", say: "task", ts: now - 2000, text: "Initial task" }], + }) + + // Clear any setup calls + mockPlayFunction.mockClear() + + // First completion_result — should trigger celebration sound + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now, text: "Task completed", partial: false }, + ], + }) + + await waitFor(() => { + expect(mockPlayFunction).toHaveBeenCalledTimes(1) + }) + + // Simulate only 50ms passing — still inside the 100ms debounce window + dateNowSpy.mockReturnValue(now + 50) + + // Second completion_result with slightly different content to force useDeepCompareEffect re-fire + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now + 50, text: "Task completed again", partial: false }, + ], + }) + + // Allow time for the second state update to propagate through React effects + await new Promise((resolve) => setTimeout(resolve, 300)) + + // Debounce should have prevented the second play + expect(mockPlayFunction).toHaveBeenCalledTimes(1) + + dateNowSpy.mockRestore() + }) + + it("should allow playing the same sound type again after 100ms", async () => { + const now = 1_000_000 + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now) + + renderChatView() + + // Hydrate with initial task + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [{ type: "say", say: "task", ts: now - 2000, text: "Initial task" }], + }) + + // Clear any setup calls + mockPlayFunction.mockClear() + + // First completion_result — triggers sound + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now, text: "Task completed", partial: false }, + ], + }) + + await waitFor(() => { + expect(mockPlayFunction).toHaveBeenCalledTimes(1) + }) + + // Advance past the 100ms debounce window + dateNowSpy.mockReturnValue(now + 101) + + // Second completion_result with different content to trigger a fresh effect cycle + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now + 101, text: "Second task completed", partial: false }, + ], + }) + + // This time the debounce window has elapsed — sound should play again + await waitFor(() => { + expect(mockPlayFunction).toHaveBeenCalledTimes(2) + }) + + dateNowSpy.mockRestore() + }) +})