Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,16 +233,25 @@ 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) => {
if (!soundEnabled) {
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading