Files
roboco/panel/src/hooks/use-websocket.ts
T

356 lines
10 KiB
TypeScript
Raw Normal View History

2026-04-20 15:10:54 +02:00
"use client";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
2026-06-29 05:38:21 +02:00
import {
WebSocketConnection,
getWebSocketUrl,
2026-04-20 15:10:54 +02:00
} 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;
}
2026-04-20 15:10:54 +02:00
// =============================================================================
// 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<string, SharedConn>();
// 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);
}
2026-04-20 15:10:54 +02:00
export function useWebSocket<T>(
endpoint: string,
queryParams?: Record<string, string>,
2026-06-29 05:38:21 +02:00
enabled: boolean = true,
2026-04-20 15:10:54 +02:00
) {
const [state, setState] = useState<ConnectionState>("disconnected");
const [lastMessage, setLastMessage] = useState<T | null>(null);
const [messages, setMessages] = useState<T[]>([]);
const connectionRef = useRef<WebSocketConnection | null>(null);
const urlRef = useRef<string | null>(null);
2026-04-20 15:10:54 +02:00
// Memoize queryParams string to prevent unnecessary reconnects
2026-06-29 05:38:21 +02:00
const queryString = queryParams
? new URLSearchParams(queryParams).toString()
: "";
2026-04-20 15:10:54 +02:00
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) => {
2026-04-20 15:10:54 +02:00
const message = data as T;
setLastMessage(message);
2026-06-29 05:38:21 +02:00
setMessages((prev) => [
...prev.slice(-(STREAM_MAX_MESSAGES - 1)),
message,
]);
2026-04-20 15:10:54 +02:00
},
onStateChange: setState,
};
2026-04-20 15:10:54 +02:00
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;
2026-04-20 15:10:54 +02:00
// 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.
2026-04-20 15:10:54 +02:00
return () => {
const current = _sharedSockets.get(url);
if (current) {
current.subscribers.delete(subscriber);
if (current.subscribers.size === 0) {
current.conn.disconnect();
_sharedSockets.delete(url);
}
}
2026-04-20 15:10:54 +02:00
connectionRef.current = null;
urlRef.current = null;
2026-06-30 08:08:35 +02:00
setMessages([]);
setLastMessage(null);
setState("disconnected");
2026-04-20 15:10:54 +02:00
};
}, [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;
2026-04-20 15:10:54 +02:00
connectionRef.current?.disconnect();
if (url) _sharedSockets.delete(url);
2026-04-20 15:10:54 +02:00
connectionRef.current = null;
urlRef.current = null;
2026-04-20 15:10:54 +02:00
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) {
2026-06-29 05:38:21 +02:00
const {
state,
lastMessage,
messages,
clearMessages,
isConnected,
isConnecting,
} = useWebSocket<AgentStreamMessage>(
agentId ? "/agents/" + agentId : "",
{ viewer_id: CEO_AGENT_ID },
!!agentId,
);
2026-04-20 15:10:54 +02:00
// 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() {
2026-06-29 05:38:21 +02:00
const {
state,
lastMessage,
messages,
clearMessages,
isConnected,
isConnecting,
} = useWebSocket<NotificationMessage>(
"/notifications/" + CEO_AGENT_ID,
undefined,
true,
);
2026-04-20 15:10:54 +02:00
// 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<string>();
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]);
2026-04-20 15:10:54 +02:00
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<A2ASystemMessage>("/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,
};
}
2026-04-20 15:10:54 +02:00
// =============================================================================
// Connection Status Hook (for UI indicators)
// =============================================================================
export function useConnectionStatus() {
2026-06-29 05:38:21 +02:00
const [connections, setConnections] = useState<
Record<string, ConnectionState>
>({});
2026-04-20 15:10:54 +02:00
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(
2026-06-29 05:38:21 +02:00
(s) => s === "connected" || s === "connecting" || s === "reconnecting",
2026-04-20 15:10:54 +02:00
);
2026-06-29 05:38:21 +02:00
const allConnected = Object.values(connections).every(
(s) => s === "connected",
);
2026-04-20 15:10:54 +02:00
return {
connections,
updateConnection,
removeConnection,
hasActiveConnections,
allConnected,
};
}