Compare commits

..

1 Commits

Author SHA1 Message Date
Haileyesus
63a4869325 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.
2026-06-23 14:06:55 +03:00
4 changed files with 58 additions and 10 deletions

View File

@@ -1,20 +1,29 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { ServerEvent } from '../../../contexts/WebSocketContext'; import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; 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 { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest } from '../types/types'; import type { PendingPermissionRequest } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; 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 { interface UseChatRealtimeHandlersArgs {
subscribe: (listener: (event: ServerEvent) => void) => () => void; subscribe: (listener: (event: ServerEvent) => void) => () => void;
provider: LLMProvider; provider: LLMProvider;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentSessionId: string | null; currentSessionId: string | null;
setTokenBudget: (budget: Record<string, unknown> | null) => void; setTokenBudget: (budget: Record<string, unknown> | null) => void;
pendingPermissionRequests: PendingPermissionRequest[];
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>; streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>; accumulatedStreamRef: MutableRefObject<string>;
@@ -52,6 +61,7 @@ export function useChatRealtimeHandlers({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
setTokenBudget, setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests, setPendingPermissionRequests,
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,
@@ -62,6 +72,15 @@ export function useChatRealtimeHandlers({
onWebSocketReconnect, onWebSocketReconnect,
sessionStore, sessionStore,
}: UseChatRealtimeHandlersArgs) { }: 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(() => { useEffect(() => {
const handleEvent = (msg: ServerEvent) => { const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) { if (!msg.kind) {
@@ -101,7 +120,16 @@ export function useChatRealtimeHandlers({
const isViewedSession = sid === activeViewSessionId; const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) { 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; return;
} }
@@ -203,6 +231,7 @@ export function useChatRealtimeHandlers({
// hides it immediately and atomically. // hides it immediately and atomically.
onSessionIdle?.(sid); onSessionIdle?.(sid);
if (sid === activeViewSessionId) { if (sid === activeViewSessionId) {
pendingPermissionRequestsRef.current = [];
setPendingPermissionRequests([]); setPendingPermissionRequests([]);
} }
@@ -235,9 +264,9 @@ export function useChatRealtimeHandlers({
case 'permission_request': { case 'permission_request': {
if (!msg.requestId) break; if (!msg.requestId) break;
if (sid === activeViewSessionId) { if (sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => { const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
return [...prev, { const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
requestId: msg.requestId as string, requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool', toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input, input: msg.input,
@@ -245,7 +274,17 @@ export function useChatRealtimeHandlers({
sessionId: sid || null, sessionId: sid || null,
receivedAt: new Date(), receivedAt: new Date(),
}]; }];
});
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
if (
isActionablePermissionRequest({ toolName: msg.toolName })
&& !hasActionablePermissionRequests(previousPendingPermissionRequests)
) {
void playNotificationSound();
}
}
} }
if (sid) { if (sid) {
onSessionProcessing?.(sid); onSessionProcessing?.(sid);
@@ -255,7 +294,12 @@ export function useChatRealtimeHandlers({
case 'permission_cancelled': { case 'permission_cancelled': {
if (msg.requestId && sid === activeViewSessionId) { 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; break;
} }
@@ -286,6 +330,7 @@ export function useChatRealtimeHandlers({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
setTokenBudget, setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests, setPendingPermissionRequests,
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,

View File

@@ -239,6 +239,7 @@ function ChatInterface({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
setTokenBudget, setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests, setPendingPermissionRequests,
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,

View File

@@ -114,7 +114,7 @@
}, },
"sound": { "sound": {
"title": "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", "enabled": "Enabled",
"test": "Test sound" "test": "Test sound"
}, },

View File

@@ -58,7 +58,7 @@ const playTone = (
oscillator.stop(startsAt + duration + 0.02); oscillator.stop(startsAt + duration + 0.02);
}; };
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => { export const playNotificationSound = async ({ force = false } = {}): Promise<void> => {
if (!force && !isNotificationSoundEnabled()) { if (!force && !isNotificationSoundEnabled()) {
return; return;
} }
@@ -81,3 +81,5 @@ export const playChatCompletionSound = async ({ force = false } = {}): Promise<v
console.warn('Unable to play notification sound:', error); console.warn('Unable to play notification sound:', error);
} }
}; };
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);