Compare commits

...

1 Commits

Author SHA1 Message Date
Haile
c947eaaee5 feat: play sound for pending tool requests (#918) 2026-06-25 14:57:10 +02:00
5 changed files with 65 additions and 20 deletions

View File

@@ -114,7 +114,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [providerModelsLoading, setProviderModelsLoading] = useState(true); const [providerModelsLoading, setProviderModelsLoading] = useState(true);
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false); const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
const lastProviderRef = useRef(provider);
const providerModelsRequestIdRef = useRef(0); const providerModelsRequestIdRef = useRef(0);
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => { const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
@@ -344,14 +343,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('selected-provider', selectedSession.__provider); localStorage.setItem('selected-provider', selectedSession.__provider);
}, [provider, selectedSession]); }, [provider, selectedSession]);
useEffect(() => { // Permission prompts belong to a session, not to the transient provider
if (lastProviderRef.current === provider) { // selection that is synchronized after navigation.
return;
}
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}, [provider]);
useEffect(() => { useEffect(() => {
setPendingPermissionRequests((previous) => setPendingPermissionRequests((previous) =>
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id), previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),

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,13 +72,29 @@ export function useChatRealtimeHandlers({
onWebSocketReconnect, onWebSocketReconnect,
sessionStore, sessionStore,
}: UseChatRealtimeHandlersArgs) { }: 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(() => { useEffect(() => {
const handleEvent = (msg: ServerEvent) => { const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) { if (!msg.kind) {
return; return;
} }
const activeViewSessionId = selectedSession?.id || currentSessionId || null; const activeViewSessionId = activeViewSessionIdRef.current;
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId; const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
// Record replay progress for every sequenced live event. // Record replay progress for every sequenced live event.
@@ -101,7 +127,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 +238,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([]);
} }
@@ -234,10 +270,14 @@ export function useChatRealtimeHandlers({
case 'permission_request': { case 'permission_request': {
if (!msg.requestId) break; if (!msg.requestId) break;
if (isActionablePermissionRequest({ toolName: msg.toolName })) {
void playNotificationSound();
}
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 +285,10 @@ export function useChatRealtimeHandlers({
sessionId: sid || null, sessionId: sid || null,
receivedAt: new Date(), receivedAt: new Date(),
}]; }];
});
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
}
} }
if (sid) { if (sid) {
onSessionProcessing?.(sid); onSessionProcessing?.(sid);
@@ -255,7 +298,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 +334,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);