mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 13:35: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 { 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,
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ function ChatInterface({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
setTokenBudget,
|
setTokenBudget,
|
||||||
|
pendingPermissionRequests,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
streamTimerRef,
|
streamTimerRef,
|
||||||
accumulatedStreamRef,
|
accumulatedStreamRef,
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user