2026-04-20 15:10:54 +02:00
|
|
|
"use client";
|
|
|
|
|
|
2026-06-08 04:47:35 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 00:07:55 +02:00
|
|
|
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
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
// 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);
|
2026-07-07 10:09:23 +02:00
|
|
|
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 : "");
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
// 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-07-07 10:09:23 +02:00
|
|
|
};
|
2026-04-20 15:10:54 +02:00
|
|
|
|
2026-07-07 10:09:23 +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
|
|
|
|
2026-07-07 10:09:23 +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 () => {
|
2026-07-07 10:09:23 +02:00
|
|
|
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;
|
2026-07-07 10:09:23 +02:00
|
|
|
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(() => {
|
2026-07-07 10:09:23 +02:00
|
|
|
// 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();
|
2026-07-07 10:09:23 +02:00
|
|
|
if (url) _sharedSockets.delete(url);
|
2026-04-20 15:10:54 +02:00
|
|
|
connectionRef.current = null;
|
2026-07-07 10:09:23 +02:00
|
|
|
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
|
|
|
|
2026-06-08 04:47:35 +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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 00:07:55 +02:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}
|