"use client"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { WebSocketConnection, getWebSocketUrl, } from "@/lib/websocket/connection"; import { CEO_AGENT_ID, STREAM_MAX_MESSAGES } from "@/lib/constants"; // Re-export ConnectionState type export type { ConnectionState } from "@/lib/websocket/connection"; import type { ConnectionState } from "@/lib/websocket/connection"; // ============================================================================= // Types // ============================================================================= export interface AgentStreamMessage { type: "connected" | "agent.stream"; agent_id: string; chunk?: string; watcher_count?: number; timestamp?: string; } export interface NotificationMessage { type: "connected" | "notification"; agent_id?: string; notification_id?: string; notification_type?: string; subject?: string; priority?: string; timestamp?: string; } export interface A2ASystemMessage { type: "connected" | "a2a.message"; conversation_id?: string; message_id?: string; task_id?: string; from_agent?: string; to_agent?: string; skill?: string | null; body_excerpt?: string; timestamp?: string; } // ============================================================================= // Generic WebSocket Hook // ============================================================================= // C4: ref-counted shared connection per URL. Two consumers of /ws/system (the // A2A live view + the rate-limit banner) used to each open their own socket; // now they share one. The shared conn fans out messages + state to a Set of // subscribers. Module-level so separate trees share the same registry. interface SharedConn { conn: WebSocketConnection; subscribers: Set<{ onMessage: (data: unknown) => void; onStateChange: (state: ConnectionState) => void; }>; } const _sharedSockets = new Map(); // Test-only: clear the module-level registry between tests so a prior test's // un-unmounted socket doesn't leak into the next. No-op in production. export function _resetSharedSocketsForTest() { for (const entry of _sharedSockets.values()) entry.conn.disconnect(); _sharedSockets.clear(); } function _dispatchMessage(url: string, data: unknown) { const entry = _sharedSockets.get(url); if (!entry) return; for (const sub of entry.subscribers) sub.onMessage(data); } function _dispatchState(url: string, state: ConnectionState) { const entry = _sharedSockets.get(url); if (!entry) return; for (const sub of entry.subscribers) sub.onStateChange(state); } export function useWebSocket( endpoint: string, queryParams?: Record, enabled: boolean = true, ) { const [state, setState] = useState("disconnected"); const [lastMessage, setLastMessage] = useState(null); const [messages, setMessages] = useState([]); const connectionRef = useRef(null); const urlRef = useRef(null); // Memoize queryParams string to prevent unnecessary reconnects const queryString = queryParams ? new URLSearchParams(queryParams).toString() : ""; useEffect(() => { // Don't connect if disabled or no endpoint if (!enabled || !endpoint) { return; } // Build URL const baseUrl = getWebSocketUrl(); const url = baseUrl + endpoint + (queryString ? "?" + queryString : ""); // Subscriber for this mount — its callbacks write THIS hook's React state. const subscriber = { onMessage: (data: unknown) => { const message = data as T; setLastMessage(message); setMessages((prev) => [ ...prev.slice(-(STREAM_MAX_MESSAGES - 1)), message, ]); }, onStateChange: setState, }; let entry = _sharedSockets.get(url); if (entry) { // Reuse: attach to the existing conn's fan-out. Replay current state so // the new subscriber's UI doesn't sit on "disconnected" until the next // state change. Route through the subscriber callback (not setState // directly) — same path the conn's onStateChange uses, so the new // subscriber mirrors the existing subscribers' current view. entry.subscribers.add(subscriber); subscriber.onStateChange(entry.conn.getState()); } else { // First subscriber for this URL — open the shared conn with fan-out // dispatchers that iterate the subscriber set. const conn = new WebSocketConnection({ url, onMessage: (data) => _dispatchMessage(url, data), onStateChange: (s) => _dispatchState(url, s), }); entry = { conn, subscribers: new Set([subscriber]) }; _sharedSockets.set(url, entry); conn.connect(); } connectionRef.current = entry.conn; urlRef.current = url; // Cleanup on unmount or when dependencies change. Decrement the refcount; // only disconnect + drop the registry entry when the last subscriber // leaves. Always clear THIS subscriber's local snapshot (#79) so a dep // change can't surface another stream's stale buffer as live. return () => { const current = _sharedSockets.get(url); if (current) { current.subscribers.delete(subscriber); if (current.subscribers.size === 0) { current.conn.disconnect(); _sharedSockets.delete(url); } } connectionRef.current = null; urlRef.current = null; setMessages([]); setLastMessage(null); setState("disconnected"); }; }, [enabled, endpoint, queryString]); // Stable dependencies const disconnect = useCallback(() => { // ponytail: manual verb tears down the shared conn for ALL subscribers of // this URL and evicts the dead entry so a later mount reopens a fresh conn // instead of reusing a manualClose=true stub that never reconnects. const url = urlRef.current; connectionRef.current?.disconnect(); if (url) _sharedSockets.delete(url); connectionRef.current = null; urlRef.current = null; setState("disconnected"); }, []); const clearMessages = useCallback(() => { setMessages([]); setLastMessage(null); }, []); return { state, lastMessage, messages, disconnect, clearMessages, isConnected: state === "connected", isConnecting: state === "connecting" || state === "reconnecting", }; } // ============================================================================= // Specialized Hooks // ============================================================================= /** * Subscribe to an agent's output stream */ export function useAgentStream(agentId: string | null) { const { state, lastMessage, messages, clearMessages, isConnected, isConnecting, } = useWebSocket( agentId ? "/agents/" + agentId : "", { viewer_id: CEO_AGENT_ID }, !!agentId, ); // Extract stream chunks const streamChunks = messages .filter((m) => m.type === "agent.stream" && m.chunk) .map((m) => m.chunk as string); // Combine chunks into full output const streamOutput = streamChunks.join(""); return { state, lastMessage, messages, streamChunks, streamOutput, clearMessages, isConnected, isConnecting, }; } /** * Subscribe to notifications for the CEO */ export function useNotificationStream() { const { state, lastMessage, messages, clearMessages, isConnected, isConnecting, } = useWebSocket( "/notifications/" + CEO_AGENT_ID, undefined, true, ); // Filter to notification events, de-duplicated by notification_id so a // stream replay (e.g. after a websocket reconnect) does not surface — or // count — the same notification twice. Walk newest→oldest keeping the most // recent copy of each id, then restore arrival order. Events without an id // (older payloads) are always kept. const notifications = useMemo(() => { const seen = new Set(); const deduped: NotificationMessage[] = []; for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m.type !== "notification") continue; const id = m.notification_id; if (id) { if (seen.has(id)) continue; seen.add(id); } deduped.push(m); } deduped.reverse(); return deduped; }, [messages]); return { state, lastMessage, notifications, allMessages: messages, clearMessages, isConnected, isConnecting, }; } /** * Subscribe to live A2A traffic on the operator stream (`/ws/system`). * * The bridge publishes an `a2a.message` frame for every persisted * agent<->agent chat message. Frames carry a capped `body_excerpt` only — * consumers invalidate their REST queries for full bodies (the A2A live view * idiom), never render the excerpt as the message. */ export function useA2ALiveStream() { const { state, lastMessage, messages, clearMessages, isConnected, isConnecting, } = useWebSocket("/system", undefined, true); // Filter to A2A frames (the system stream also carries rate-limit/usage). const a2aMessages = messages.filter((m) => m.type === "a2a.message"); return { state, lastMessage, a2aMessages, allMessages: messages, clearMessages, isConnected, isConnecting, }; } // ============================================================================= // Connection Status Hook (for UI indicators) // ============================================================================= export function useConnectionStatus() { const [connections, setConnections] = useState< Record >({}); const updateConnection = useCallback((id: string, state: ConnectionState) => { setConnections((prev) => ({ ...prev, [id]: state })); }, []); const removeConnection = useCallback((id: string) => { setConnections((prev) => { const next = { ...prev }; delete next[id]; return next; }); }, []); const hasActiveConnections = Object.values(connections).some( (s) => s === "connected" || s === "connecting" || s === "reconnecting", ); const allConnected = Object.values(connections).every( (s) => s === "connected", ); return { connections, updateConnection, removeConnection, hasActiveConnections, allConnected, }; }