generated from nhcarrigan/template
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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user