diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index a2910b0d..ea49d841 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -114,7 +114,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const [providerModelsLoading, setProviderModelsLoading] = useState(true); const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false); - const lastProviderRef = useRef(provider); const providerModelsRequestIdRef = useRef(0); const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => { @@ -344,14 +343,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh localStorage.setItem('selected-provider', selectedSession.__provider); }, [provider, selectedSession]); - useEffect(() => { - if (lastProviderRef.current === provider) { - return; - } - setPendingPermissionRequests([]); - lastProviderRef.current = provider; - }, [provider]); - + // Permission prompts belong to a session, not to the transient provider + // selection that is synchronized after navigation. useEffect(() => { setPendingPermissionRequests((previous) => previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id), diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 5c294efa..80826486 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,13 +72,29 @@ export function useChatRealtimeHandlers({ onWebSocketReconnect, sessionStore, }: UseChatRealtimeHandlersArgs) { + // Session switches can send `chat.subscribe` before this effect has a chance + // to rebind the websocket listener. Read the visible session id from a ref + // so a fast `chat_subscribed` ack is matched against the current view, not + // the previous render's closed-over selection. + const activeViewSessionIdRef = useRef(selectedSession?.id || currentSessionId || null); + activeViewSessionIdRef.current = selectedSession?.id || currentSessionId || null; + + // 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) { return; } - const activeViewSessionId = selectedSession?.id || currentSessionId || null; + const activeViewSessionId = activeViewSessionIdRef.current; const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId; // Record replay progress for every sequenced live event. @@ -101,7 +127,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 +238,7 @@ export function useChatRealtimeHandlers({ // hides it immediately and atomically. onSessionIdle?.(sid); if (sid === activeViewSessionId) { + pendingPermissionRequestsRef.current = []; setPendingPermissionRequests([]); } @@ -234,10 +270,14 @@ export function useChatRealtimeHandlers({ case 'permission_request': { if (!msg.requestId) break; + if (isActionablePermissionRequest({ toolName: msg.toolName })) { + void playNotificationSound(); + } + 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 +285,10 @@ export function useChatRealtimeHandlers({ sessionId: sid || null, receivedAt: new Date(), }]; - }); + + pendingPermissionRequestsRef.current = nextPendingPermissionRequests; + setPendingPermissionRequests(nextPendingPermissionRequests); + } } if (sid) { onSessionProcessing?.(sid); @@ -255,7 +298,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 +334,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 45ed8df8..a83dfbdc 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -240,6 +240,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 2d9772b1..a04067e2 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -131,7 +131,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);