import { useEffect } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { ServerEvent } from '../../../contexts/WebSocketContext'; import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; 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'; interface UseChatRealtimeHandlersArgs { subscribe: (listener: (event: ServerEvent) => void) => () => void; provider: LLMProvider; selectedSession: ProjectSession | null; currentSessionId: string | null; setTokenBudget: (budget: Record | null) => void; setPendingPermissionRequests: Dispatch>; streamTimerRef: MutableRefObject; accumulatedStreamRef: MutableRefObject; /** * Highest live `seq` observed per session. Essential for reconnect catch-up: * `chat.subscribe` sends this value as `lastSeq` so the server replays only * the events this client actually missed. Written here on every sequenced * frame; read wherever a `chat.subscribe` is sent (session open, reconnect). */ lastSeqRef: MutableRefObject>; /** When each session's `chat.subscribe` was last sent; guards stale idle acks. */ statusCheckSentAtRef: MutableRefObject>; onSessionProcessing?: MarkSessionProcessing; onSessionIdle?: MarkSessionIdle; onWebSocketReconnect?: () => void; sessionStore: SessionStore; } /* ------------------------------------------------------------------ */ /* Hook */ /* ------------------------------------------------------------------ */ /** * Routes server events into the session store and processing-state map. * * This is intentionally a thin reducer over the unified `kind`-based * protocol: every frame is keyed by the stable app session id, so there is * no session-id handoff, no provider branching, and no navigation here. * Sidebar events (`session_upserted`, `loading_progress`) are handled by * `useProjectsState`, not in this hook. */ export function useChatRealtimeHandlers({ subscribe, provider, selectedSession, currentSessionId, setTokenBudget, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, lastSeqRef, statusCheckSentAtRef, onSessionProcessing, onSessionIdle, onWebSocketReconnect, sessionStore, }: UseChatRealtimeHandlersArgs) { useEffect(() => { const handleEvent = (msg: ServerEvent) => { if (!msg.kind) { return; } const activeViewSessionId = selectedSession?.id || currentSessionId || null; const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId; // Record replay progress for every sequenced live event. if (sid && typeof msg.seq === 'number') { const known = lastSeqRef.current.get(sid) ?? 0; if (msg.seq > known) { lastSeqRef.current.set(sid, msg.seq); } } switch (msg.kind) { case 'websocket_reconnected': onWebSocketReconnect?.(); return; case 'chat_subscribed': { // Ack for chat.subscribe: authoritative processing state plus any // pending tool-permission prompts for the run. if (!sid) return; if (msg.isProcessing) { onSessionProcessing?.(sid); } else { // Idle ack: ignore it if a newer request started after the // subscribe was sent — the ack describes the older state. onSessionIdle?.(sid, { ifStartedBefore: statusCheckSentAtRef.current.get(sid), }); } const isViewedSession = sid === activeViewSessionId; if (isViewedSession && Array.isArray(msg.pendingPermissions)) { setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]); } return; } case 'protocol_error': { console.error('[Chat] Protocol error:', msg.code, msg.error); if (sid) { // Surface the failure in the conversation and stop the spinner — // the run never started (or was rejected), so no `complete` follows. onSessionIdle?.(sid); sessionStore.appendRealtime(sid, { id: `protocol_error_${Date.now()}`, sessionId: sid, timestamp: new Date().toISOString(), provider, kind: 'error', content: String(msg.error || 'Request failed'), } as NormalizedMessage); } return; } // Sidebar/global events — owned by useProjectsState. case 'session_upserted': case 'loading_progress': return; default: break; } /* -------------------------------------------------------------- */ /* Provider NormalizedMessage handling */ /* -------------------------------------------------------------- */ // --- Streaming: buffer for performance --- if (msg.kind === 'stream_delta') { const text = (msg.content as string) || ''; 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 unknown 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 !== 'complete' && msg.kind !== 'status' && msg.kind !== 'permission_request' && msg.kind !== 'permission_cancelled'; if (sid && shouldPersist) { sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage); } // --- UI side effects for specific kinds --- switch (msg.kind) { 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 = ''; // `complete` is the unified terminal event — every provider run ends // with exactly one, regardless of success, failure, or abort. The // indicator derives from the processing map, so deleting the entry // hides it immediately and atomically. onSessionIdle?.(sid); setPendingPermissionRequests([]); if (msg.aborted) { // Abort was requested — the complete event confirms it. No // further UI action is needed beyond clearing the entry above. break; } // Celebrate only successful runs (failed runs end with success: false). if (msg.success !== false) { showCompletionTitleIndicator(); void playChatCompletionSound(); } // The session id is stable for the whole conversation (allocated // before the first send), so the only follow-up is syncing the // viewed conversation with the now-persisted transcript. if (sid && sid === activeViewSessionId) { void sessionStore.refreshFromServer(sid); } break; } // 'error' is an informational message row, not a terminal event — // providers emit it for mid-run stderr output too. Run teardown is // always signalled by the unified 'complete' that follows. 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 as string, toolName: (msg.toolName as string) || 'UnknownTool', input: msg.input, context: msg.context, sessionId: sid || null, receivedAt: new Date(), }]; }); if (sid) { onSessionProcessing?.(sid); } 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); } else if (msg.text && sid) { onSessionProcessing?.(sid, { statusText: msg.text as string, canInterrupt: 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; } }; return subscribe(handleEvent); }, [ subscribe, provider, selectedSession, currentSessionId, setTokenBudget, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, lastSeqRef, statusCheckSentAtRef, onSessionProcessing, onSessionIdle, onWebSocketReconnect, sessionStore, ]); }