feat: play sound for pending tool requests (#918)

This commit is contained in:
Haile
2026-06-25 15:57:10 +03:00
committed by GitHub
parent 4a503b1dc8
commit c947eaaee5
5 changed files with 65 additions and 20 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 { 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<string, unknown> | null) => void;
pendingPermissionRequests: PendingPermissionRequest[];
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
@@ -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<string | null>(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,