From 63c8dcdf13d85a7f641cc88195a1e5f1db4d0180 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 26 Feb 2026 21:17:57 -0800 Subject: [PATCH] fix: move all notification sounds to per-conversation event tracking Prevents sounds from re-firing on tab switch and ensures background tab completions receive their sounds. All sounds now fire from the claude:state event handler in tauri.ts using per-conversation flags, with rules.ts retained only for the connection sound. Closes #172 --- src/lib/notifications/rules.ts | 80 +++------------------------------ src/lib/stores/claude.ts | 6 +++ src/lib/stores/conversations.ts | 59 +++++++++++++++++++++++- src/lib/tauri.ts | 58 ++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 76 deletions(-) diff --git a/src/lib/notifications/rules.ts b/src/lib/notifications/rules.ts index 6756e28..8d62d62 100644 --- a/src/lib/notifications/rules.ts +++ b/src/lib/notifications/rules.ts @@ -1,52 +1,8 @@ -import { characterState } from "$lib/stores/character"; import { notificationManager } from "./notificationManager"; -import type { CharacterState } from "$lib/types/states"; import type { ConnectionStatus } from "$lib/types/messages"; -// Track previous states to detect transitions -let previousCharacterState: CharacterState | null = null; +// Track previous connection status to detect transitions let previousConnectionStatus: ConnectionStatus | null = null; -let taskStartTime: number | null = null; -let hasNotifiedTaskStart = false; - -export function handleCharacterStateChange(newState: CharacterState): void { - // Detect state transitions - if (previousCharacterState === newState) return; - - // Task completion: any state -> success - if (newState === "success" && previousCharacterState !== null) { - const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0; - // Only notify for tasks that took more than 2 seconds - if (taskDuration > 2000) { - notificationManager.notifySuccess(); - } - taskStartTime = null; - } - - // Error occurred - if (newState === "error" && previousCharacterState !== "error") { - notificationManager.notifyError(); - } - - // Permission needed - if (newState === "permission") { - notificationManager.notifyPermission(); - } - - // Starting long tasks - only notify once per response - if ( - (newState === "coding" || newState === "searching") && - previousCharacterState !== "coding" && - previousCharacterState !== "searching" && - !hasNotifiedTaskStart - ) { - taskStartTime = Date.now(); - hasNotifiedTaskStart = true; - notificationManager.notifyTaskStart(); - } - - previousCharacterState = newState; -} export function handleConnectionStatusChange(newStatus: ConnectionStatus): void { // Only notify on successful connection after being disconnected @@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void { // But we could add specific rules here if needed } -// Reset notification state for a new response -export function handleNewUserMessage(): void { - hasNotifiedTaskStart = false; -} +// No-op: sound tracking is now per-conversation in tauri.ts +export function handleNewUserMessage(): void {} -// Store unsubscribe functions -let unsubscribeCharacterState: (() => void) | null = null; +// No-op: all per-conversation sounds are driven by tauri.ts event listeners +export function initializeNotificationRules(): void {} -// Initialize listeners -export function initializeNotificationRules(): void { - // Clean up any existing subscriptions first - cleanupNotificationRules(); - - // Subscribe to character state changes - unsubscribeCharacterState = characterState.subscribe((state) => { - handleCharacterStateChange(state); - }); - - // We'll connect to connection status in the next step -} - -// Cleanup function to prevent duplicate listeners +// Cleanup — reset connection tracking on teardown export function cleanupNotificationRules(): void { - if (unsubscribeCharacterState) { - unsubscribeCharacterState(); - unsubscribeCharacterState = null; - } - - // Reset state tracking - previousCharacterState = null; previousConnectionStatus = null; - taskStartTime = null; - hasNotifiedTaskStart = false; } diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index 630a6c8..ccfc441 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -60,6 +60,12 @@ export const claudeStore = { isToolGranted: conversationsStore.isToolGranted, setPendingRetryMessage: conversationsStore.setPendingRetryMessage, + // Sound tracking + resetSoundState: conversationsStore.resetSoundState, + setTaskStartTime: conversationsStore.setTaskStartTime, + markSuccessSoundFired: conversationsStore.markSuccessSoundFired, + markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired, + // Conversation management createConversation: conversationsStore.createConversation, deleteConversation: conversationsStore.deleteConversation, diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index 3b9be8f..3a3ff51 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -37,6 +37,9 @@ export interface Conversation { attachments: Attachment[]; summary: ConversationSummary | null; startedAt: Date; + taskStartTime: number | null; + successSoundFired: boolean; + taskStartSoundFired: boolean; } function createConversationsStore() { @@ -75,6 +78,9 @@ function createConversationsStore() { attachments: [], summary: null, startedAt: new Date(), + taskStartTime: null, + successSoundFired: false, + taskStartSoundFired: false, }; } @@ -364,9 +370,15 @@ function createConversationsStore() { if (currentId !== id) { activeConversationId.set(id); - // Update the global character state to match the conversation's state + // Update the global character state to match the conversation's state. + // Map success/error → idle since those are transient states that have + // already been displayed — restoring them would re-trigger sound rules. if (targetConv) { - characterState.setState(targetConv.characterState); + const stateToRestore = + targetConv.characterState === "success" || targetConv.characterState === "error" + ? "idle" + : targetConv.characterState; + characterState.setState(stateToRestore); } } }, @@ -816,6 +828,49 @@ function createConversationsStore() { }); }, + // Sound tracking methods + resetSoundState: (conversationId: string) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.taskStartTime = null; + conv.successSoundFired = false; + conv.taskStartSoundFired = false; + } + return convs; + }); + }, + + setTaskStartTime: (conversationId: string, time: number | null) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.taskStartTime = time; + } + return convs; + }); + }, + + markSuccessSoundFired: (conversationId: string) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.successSoundFired = true; + } + return convs; + }); + }, + + markTaskStartSoundFired: (conversationId: string) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.taskStartSoundFired = true; + } + return convs; + }); + }, + // Add initialization helper initialize: () => { ensureInitialized(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ad26771..16e9e7d 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -21,6 +21,7 @@ import { handleConnectionStatusChange, handleNewUserMessage, } from "$lib/notifications/rules"; +import { notificationManager } from "$lib/notifications/notificationManager"; interface StateChangePayload { state: CharacterState; @@ -270,6 +271,63 @@ export async function initializeTauriListeners() { const mappedState = stateMap[state.toLowerCase()] || "idle"; + // Per-conversation sound tracking — fires for any tab (active or background). + // All sounds are driven from state-change events rather than a global store + // subscription, so background tabs receive their sounds correctly and + // switching tabs never replays a sound that has already fired. + const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null; + if (resolvedConversationId) { + const conv = get(claudeStore.conversations).get(resolvedConversationId); + if (conv) { + const previousState = conv.characterState; + + // New response starting — clear all per-task sound flags. + if (mappedState === "thinking") { + claudeStore.resetSoundState(resolvedConversationId); + } + + // Record when a long-running phase begins (used for the 2-second + // minimum duration check before playing the completion sound). + if ( + (mappedState === "coding" || mappedState === "searching") && + previousState !== "coding" && + previousState !== "searching" + ) { + claudeStore.setTaskStartTime(resolvedConversationId, Date.now()); + } + + // Task-start sound — fires once when work enters a long-running phase. + if ( + (mappedState === "coding" || mappedState === "searching") && + previousState !== "coding" && + previousState !== "searching" && + !conv.taskStartSoundFired + ) { + notificationManager.notifyTaskStart(); + claudeStore.markTaskStartSoundFired(resolvedConversationId); + } + + // Error sound — fires each time a new error state is entered. + if (mappedState === "error" && previousState !== "error") { + notificationManager.notifyError(); + } + + // Permission sound — fires each time a permission request arrives. + if (mappedState === "permission") { + notificationManager.notifyPermission(); + } + + // Completion sound — fires once per task after sufficient duration. + if (mappedState === "success" && !conv.successSoundFired) { + const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0; + if (duration > 2000) { + notificationManager.notifySuccess(); + } + claudeStore.markSuccessSoundFired(resolvedConversationId); + } + } + } + // Always update the conversation's state if (conversation_id) { claudeStore.setCharacterStateForConversation(conversation_id, mappedState);