diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 5c294efa..9fceecd4 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,20 +1,29 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { ServerEvent } from '../../../contexts/WebSocketContext'; import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; -import { playChatCompletionSound } from '../../../utils/notificationSound'; +import { playChatCompletionSound, playNotificationSound } from '../../../utils/notificationSound'; import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import type { PendingPermissionRequest } from '../types/types'; import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; +const isActionablePermissionRequest = (request: { toolName?: unknown } | null | undefined): boolean => { + return request?.toolName !== 'ExitPlanMode' && request?.toolName !== 'exit_plan_mode'; +}; + +const hasActionablePermissionRequests = (requests: Array<{ toolName?: unknown }> | null | undefined): boolean => { + return Array.isArray(requests) && requests.some((request) => isActionablePermissionRequest(request)); +}; + interface UseChatRealtimeHandlersArgs { subscribe: (listener: (event: ServerEvent) => void) => () => void; provider: LLMProvider; selectedSession: ProjectSession | null; currentSessionId: string | null; setTokenBudget: (budget: Record | null) => void; + pendingPermissionRequests: PendingPermissionRequest[]; setPendingPermissionRequests: Dispatch>; streamTimerRef: MutableRefObject; accumulatedStreamRef: MutableRefObject; @@ -52,6 +61,7 @@ export function useChatRealtimeHandlers({ selectedSession, currentSessionId, setTokenBudget, + pendingPermissionRequests, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, @@ -62,6 +72,15 @@ export function useChatRealtimeHandlers({ onWebSocketReconnect, sessionStore, }: UseChatRealtimeHandlersArgs) { + // Keep the latest pending-permission snapshot available to the websocket + // listener so back-to-back permission events can dedupe and re-arm the + // notification sound before React finishes a rerender. + const pendingPermissionRequestsRef = useRef(pendingPermissionRequests); + + useEffect(() => { + pendingPermissionRequestsRef.current = pendingPermissionRequests; + }, [pendingPermissionRequests]); + useEffect(() => { const handleEvent = (msg: ServerEvent) => { if (!msg.kind) { @@ -101,7 +120,16 @@ export function useChatRealtimeHandlers({ const isViewedSession = sid === activeViewSessionId; if (isViewedSession && Array.isArray(msg.pendingPermissions)) { - setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]); + const nextPendingPermissionRequests = msg.pendingPermissions as PendingPermissionRequest[]; + const hadActionablePermissionRequests = hasActionablePermissionRequests(pendingPermissionRequestsRef.current); + const hasPendingActionablePermissionRequests = hasActionablePermissionRequests(nextPendingPermissionRequests); + + pendingPermissionRequestsRef.current = nextPendingPermissionRequests; + setPendingPermissionRequests(nextPendingPermissionRequests); + + if (hasPendingActionablePermissionRequests && !hadActionablePermissionRequests) { + void playNotificationSound(); + } } return; } @@ -203,6 +231,7 @@ export function useChatRealtimeHandlers({ // hides it immediately and atomically. onSessionIdle?.(sid); if (sid === activeViewSessionId) { + pendingPermissionRequestsRef.current = []; setPendingPermissionRequests([]); } @@ -235,9 +264,9 @@ export function useChatRealtimeHandlers({ case 'permission_request': { if (!msg.requestId) break; if (sid === activeViewSessionId) { - setPendingPermissionRequests((prev) => { - if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; - return [...prev, { + const previousPendingPermissionRequests = pendingPermissionRequestsRef.current; + if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) { + const nextPendingPermissionRequests = [...previousPendingPermissionRequests, { requestId: msg.requestId as string, toolName: (msg.toolName as string) || 'UnknownTool', input: msg.input, @@ -245,7 +274,17 @@ export function useChatRealtimeHandlers({ sessionId: sid || null, receivedAt: new Date(), }]; - }); + + pendingPermissionRequestsRef.current = nextPendingPermissionRequests; + setPendingPermissionRequests(nextPendingPermissionRequests); + + if ( + isActionablePermissionRequest({ toolName: msg.toolName }) + && !hasActionablePermissionRequests(previousPendingPermissionRequests) + ) { + void playNotificationSound(); + } + } } if (sid) { onSessionProcessing?.(sid); @@ -255,7 +294,12 @@ export function useChatRealtimeHandlers({ case 'permission_cancelled': { if (msg.requestId && sid === activeViewSessionId) { - setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); + const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter( + (request: PendingPermissionRequest) => request.requestId !== msg.requestId, + ); + + pendingPermissionRequestsRef.current = nextPendingPermissionRequests; + setPendingPermissionRequests(nextPendingPermissionRequests); } break; } @@ -286,6 +330,7 @@ export function useChatRealtimeHandlers({ selectedSession, currentSessionId, setTokenBudget, + pendingPermissionRequests, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 5efe6af4..15786a41 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -239,6 +239,7 @@ function ChatInterface({ selectedSession, currentSessionId, setTokenBudget, + pendingPermissionRequests, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index fbcd797a..89d4e651 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -114,7 +114,7 @@ }, "sound": { "title": "Sound", - "description": "Play a short tone when a chat run finishes.", + "description": "Play a short tone when a chat run finishes or needs tool approval.", "enabled": "Enabled", "test": "Test sound" }, diff --git a/src/utils/notificationSound.ts b/src/utils/notificationSound.ts index 78af2d99..d28d6659 100644 --- a/src/utils/notificationSound.ts +++ b/src/utils/notificationSound.ts @@ -58,7 +58,7 @@ const playTone = ( oscillator.stop(startsAt + duration + 0.02); }; -export const playChatCompletionSound = async ({ force = false } = {}): Promise => { +export const playNotificationSound = async ({ force = false } = {}): Promise => { if (!force && !isNotificationSoundEnabled()) { return; } @@ -81,3 +81,5 @@ export const playChatCompletionSound = async ({ force = false } = {}): Promise => playNotificationSound(options);