mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-25 04:13:51 +08:00
Compare commits
2 Commits
main
...
feat/pendi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16be1d0f7b | ||
|
|
63a4869325 |
@@ -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),
|
||||||
|
|||||||
@@ -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([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,9 +271,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 +281,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 +301,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 +337,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