|
|
|
|
@@ -1,29 +1,20 @@
|
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
|
import { useEffect } from 'react';
|
|
|
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
|
|
|
|
|
|
|
|
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
|
|
|
|
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
|
|
|
|
import { playChatCompletionSound, playNotificationSound } from '../../../utils/notificationSound';
|
|
|
|
|
import { playChatCompletionSound } 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>;
|
|
|
|
|
@@ -61,7 +52,6 @@ export function useChatRealtimeHandlers({
|
|
|
|
|
selectedSession,
|
|
|
|
|
currentSessionId,
|
|
|
|
|
setTokenBudget,
|
|
|
|
|
pendingPermissionRequests,
|
|
|
|
|
setPendingPermissionRequests,
|
|
|
|
|
streamTimerRef,
|
|
|
|
|
accumulatedStreamRef,
|
|
|
|
|
@@ -72,29 +62,13 @@ 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 = activeViewSessionIdRef.current;
|
|
|
|
|
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
|
|
|
|
|
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
|
|
|
|
|
|
|
|
|
// Record replay progress for every sequenced live event.
|
|
|
|
|
@@ -127,16 +101,7 @@ export function useChatRealtimeHandlers({
|
|
|
|
|
|
|
|
|
|
const isViewedSession = sid === activeViewSessionId;
|
|
|
|
|
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
@@ -238,7 +203,6 @@ export function useChatRealtimeHandlers({
|
|
|
|
|
// hides it immediately and atomically.
|
|
|
|
|
onSessionIdle?.(sid);
|
|
|
|
|
if (sid === activeViewSessionId) {
|
|
|
|
|
pendingPermissionRequestsRef.current = [];
|
|
|
|
|
setPendingPermissionRequests([]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -270,14 +234,10 @@ export function useChatRealtimeHandlers({
|
|
|
|
|
|
|
|
|
|
case 'permission_request': {
|
|
|
|
|
if (!msg.requestId) break;
|
|
|
|
|
if (isActionablePermissionRequest({ toolName: msg.toolName })) {
|
|
|
|
|
void playNotificationSound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sid === activeViewSessionId) {
|
|
|
|
|
const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
|
|
|
|
|
if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
|
|
|
|
|
const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
|
|
|
|
|
setPendingPermissionRequests((prev) => {
|
|
|
|
|
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
|
|
|
|
return [...prev, {
|
|
|
|
|
requestId: msg.requestId as string,
|
|
|
|
|
toolName: (msg.toolName as string) || 'UnknownTool',
|
|
|
|
|
input: msg.input,
|
|
|
|
|
@@ -285,10 +245,7 @@ export function useChatRealtimeHandlers({
|
|
|
|
|
sessionId: sid || null,
|
|
|
|
|
receivedAt: new Date(),
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
|
|
|
|
setPendingPermissionRequests(nextPendingPermissionRequests);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (sid) {
|
|
|
|
|
onSessionProcessing?.(sid);
|
|
|
|
|
@@ -298,12 +255,7 @@ export function useChatRealtimeHandlers({
|
|
|
|
|
|
|
|
|
|
case 'permission_cancelled': {
|
|
|
|
|
if (msg.requestId && sid === activeViewSessionId) {
|
|
|
|
|
const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter(
|
|
|
|
|
(request: PendingPermissionRequest) => request.requestId !== msg.requestId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
|
|
|
|
setPendingPermissionRequests(nextPendingPermissionRequests);
|
|
|
|
|
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
@@ -334,7 +286,6 @@ export function useChatRealtimeHandlers({
|
|
|
|
|
selectedSession,
|
|
|
|
|
currentSessionId,
|
|
|
|
|
setTokenBudget,
|
|
|
|
|
pendingPermissionRequests,
|
|
|
|
|
setPendingPermissionRequests,
|
|
|
|
|
streamTimerRef,
|
|
|
|
|
accumulatedStreamRef,
|
|
|
|
|
|