Files
claudecodeui/src/components/chat/hooks/useChatRealtimeHandlers.ts
Haileyesus d70dc077bf feat: signal when chat runs complete
Users can miss chat completions while the app is in the background.

They can also miss completions when their attention is elsewhere.

Add opt-out sound notifications and a temporary title marker.

This makes completion noticeable without external audio assets or persistent browser notifications.
2026-06-09 13:51:51 +03:00

400 lines
13 KiB
TypeScript

import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type PendingViewSession = {
startedAt: number;
};
type LatestChatMessage = {
type?: string;
kind?: string;
data?: any;
message?: any;
delta?: string;
sessionId?: string;
session_id?: string;
requestId?: string;
toolName?: string;
input?: unknown;
context?: unknown;
error?: string;
tool?: any;
toolId?: string;
result?: any;
exitCode?: number;
isProcessing?: boolean;
actualSessionId?: string;
event?: string;
status?: any;
isNewSession?: boolean;
resultText?: string;
isError?: boolean;
success?: boolean;
reason?: string;
provider?: string;
content?: string;
text?: string;
tokens?: number;
canInterrupt?: boolean;
tokenBudget?: unknown;
newSessionId?: string;
aborted?: boolean;
[key: string]: any;
};
interface UseChatRealtimeHandlersArgs {
latestMessage: LatestChatMessage | null;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
onSessionInactive?: (sessionId?: string | null) => void;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
onWebSocketReconnect?: () => void;
sessionStore: SessionStore;
}
/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */
export function useChatRealtimeHandlers({
latestMessage,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
const paletteOps = usePaletteOps();
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
useEffect(() => {
if (!latestMessage) return;
if (lastProcessedMessageRef.current === latestMessage) return;
lastProcessedMessageRef.current = latestMessage;
const activeViewSessionId =
selectedSession?.id || currentSessionId || null;
/* ---------------------------------------------------------------- */
/* Legacy messages (no `kind` field) — handle and return */
/* ---------------------------------------------------------------- */
const msg = latestMessage as any;
if (!msg.kind) {
const messageType = String(msg.type || '');
switch (messageType) {
case 'websocket-reconnected':
onWebSocketReconnect?.();
return;
case 'pending-permissions-response': {
const permSessionId = msg.sessionId;
const isCurrentPermSession =
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
if (permSessionId && !isCurrentPermSession) return;
setPendingPermissionRequests(msg.data || []);
return;
}
case 'session-status': {
const statusSessionId = msg.sessionId;
if (!statusSessionId) return;
const status = msg.status;
if (status) {
const statusInfo = {
text: status.text || 'Working...',
tokens: status.tokens || 0,
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
};
setClaudeStatus(statusInfo);
setIsLoading(true);
setCanAbortSession(statusInfo.can_interrupt);
return;
}
// Legacy isProcessing format from check-session-status
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (msg.isProcessing) {
onSessionActive?.(statusSessionId);
onSessionProcessing?.(statusSessionId);
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
return;
}
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
return;
}
default:
// Unknown legacy message type — ignore
return;
}
}
/* ---------------------------------------------------------------- */
/* NormalizedMessage handling (has `kind` field) */
/* ---------------------------------------------------------------- */
const sid = msg.sessionId || activeViewSessionId;
// --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') {
const text = msg.content || '';
if (!text) return;
accumulatedStreamRef.current += text;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
if (sid) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
}, 100);
}
// Also route to store for non-active sessions
if (sid && sid !== activeViewSessionId) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
return;
}
if (msg.kind === 'stream_end') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid) {
if (accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
return;
}
// --- All other messages: route to store ---
const shouldPersist =
msg.kind !== 'session_created'
&& msg.kind !== 'complete'
&& msg.kind !== 'status'
&& msg.kind !== 'permission_request'
&& msg.kind !== 'permission_cancelled';
if (sid && shouldPersist) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
// --- UI side effects for specific kinds ---
switch (msg.kind) {
case 'session_created': {
const newSessionId = msg.newSessionId;
if (!newSessionId) break;
// We no longer synthesize client-side placeholder IDs. Until the provider
// announces `session_created`, the active id is expected to be null.
if (!currentSessionId) {
console.log('Session created with ID:', newSessionId);
console.log('Existing session ID:', currentSessionId);
setCurrentSessionId(newSessionId);
setPendingPermissionRequests((prev) =>
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
);
}
pendingViewSessionRef.current = null;
onSessionActive?.(newSessionId);
onSessionProcessing?.(newSessionId);
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
onNavigateToSession?.(newSessionId);
break;
}
case 'complete': {
// Flush any remaining streaming state
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid && accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
setPendingPermissionRequests([]);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
// Handle aborted case
if (msg.aborted) {
// Abort was requested — the complete event confirms it
// No special UI action needed beyond clearing loading state above
// The backend already sent any abort-related messages
break;
}
showCompletionTitleIndicator();
void playChatCompletionSound();
const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId
: null;
const isVisibleSession =
Boolean(
sid
&& sid === activeViewSessionId,
);
if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId);
if (isVisibleSession) {
setCurrentSessionId(actualSessionId);
}
if (isVisibleSession) {
onNavigateToSession?.(actualSessionId, { replace: true });
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break;
}
break;
}
case 'error': {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
break;
}
case 'permission_request': {
if (!msg.requestId) break;
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId,
toolName: msg.toolName || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
break;
}
case 'permission_cancelled': {
if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
case 'status': {
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) {
setClaudeStatus({
text: msg.text,
tokens: msg.tokens || 0,
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
});
setIsLoading(true);
setCanAbortSession(msg.canInterrupt !== false);
}
break;
}
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
// → already routed to store above, no UI side effects needed
default:
break;
}
}, [
latestMessage,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
paletteOps,
]);
}