Explorar o código

fix: prevent double notification sound playback (#11283)

Hannes Rudolph hai 2 meses
pai
achega
f51e91ac76

+ 12 - 3
webview-ui/src/components/chat/ChatView.tsx

@@ -244,9 +244,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	const secondLastMessage = useMemo(() => messages.at(-2), [messages])
 	const secondLastMessage = useMemo(() => messages.at(-2), [messages])
 
 
 	const volume = typeof soundVolume === "number" ? soundVolume : 0.5
 	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<Record<string, number>>({})
 
 
 	const playSound = useCallback(
 	const playSound = useCallback(
 		(audioType: AudioType) => {
 		(audioType: AudioType) => {
@@ -254,6 +256,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				return
 				return
 			}
 			}
 
 
+			const now = Date.now()
+			const lastPlayed = lastPlayedRef.current[audioType] ?? 0
+			if (now - lastPlayed < 100) {
+				return
+			} // debounce: skip if played within 100ms
+			lastPlayedRef.current[audioType] = now
+
 			switch (audioType) {
 			switch (audioType) {
 				case "notification":
 				case "notification":
 					playNotification()
 					playNotification()

+ 107 - 0
webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx

@@ -520,3 +520,110 @@ describe("ChatView - Notification Sound with Queued Messages", () => {
 		)
 		)
 	})
 	})
 })
 })
+
+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()
+	})
+})