From 63a48693253716edf97dc2c97e062e7b9cad45fa Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:06:55 +0300 Subject: [PATCH] feat: play sound for pending tool requests Reuse the existing notification tone when a chat run pauses for actionable tool approval. Track pending permission state inside the realtime handler so the sound plays when approval first becomes pending, including subscribe recovery, without replaying for inline plan prompts or duplicate websocket events. --- .../chat/hooks/useChatRealtimeHandlers.ts | 61 ++++++++++++++++--- src/components/chat/view/ChatInterface.tsx | 1 + src/i18n/locales/en/settings.json | 2 +- src/utils/notificationSound.ts | 4 +- 4 files changed, 58 insertions(+), 10 deletions(-) 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);