mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-24 02:45:49 +08:00
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.
This commit is contained in:
@@ -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,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,
|
||||
|
||||
@@ -239,6 +239,7 @@ function ChatInterface({
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setTokenBudget,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ const playTone = (
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
@@ -81,3 +81,5 @@ export const playChatCompletionSound = async ({ force = false } = {}): Promise<v
|
||||
console.warn('Unable to play notification sound:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);
|
||||
|
||||
Reference in New Issue
Block a user