fix: prevent double notification sound playback (#11283)

This commit is contained in:
Hannes Rudolph 2026-02-09 16:17:36 -07:00 committed by GitHub
parent 7da429da19
commit f51e91ac76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 119 additions and 3 deletions

View file

@ -244,9 +244,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const secondLastMessage = useMemo(() => 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<Record<string, number>>({})
const playSound = useCallback(
(audioType: AudioType) => {
@ -254,6 +256,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
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) {
case "notification":
playNotification()

View file

@ -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()
})
})