Files
openswarm/frontend/src/shared/ws/WebSocketManager.ts
T

917 lines
36 KiB
TypeScript

import { store } from '../state/store';
import { unstable_batchedUpdates } from 'react-dom';
import {
updateSession,
updateSessionName,
updateGroupMeta,
addMessage,
addApprovalRequest,
removeApprovalRequest,
updateSessionStatus,
updateSessionCost,
updateSessionContext,
setContextOverflow,
setMcpSuggestions,
addBranch,
setActiveBranch,
closeSessionFromWs,
trackAgentNotification,
setSessionConnState,
fetchSession,
recordCompaction,
setTurnLabel,
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, addWorkflowCard } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard } from '../state/workflowsSlice';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
// Thin wrapper around getAuthToken so the connect() call site stays
// synchronous. If the token isn't cached yet, returns '' and the WS
// handshake will 4401 , onclose catches that and refreshes the token
// before the next reconnect.
const _getAuthTokenSafe = (): string => {
try { return getAuthToken() || ''; } catch { return ''; }
};
const _genUuid = (): string => {
// Avoid pulling in `crypto.randomUUID` for compat , this is a
// disambiguator, not a security boundary, so a 96-bit hex string is
// plenty.
const a = Math.floor(Math.random() * 2 ** 32).toString(16).padStart(8, '0');
const b = Math.floor(Math.random() * 2 ** 32).toString(16).padStart(8, '0');
const c = Math.floor(Math.random() * 2 ** 32).toString(16).padStart(8, '0');
return `${a}${b}${c}`;
};
type WSEvent = {
event: string;
session_id?: string;
data: Record<string, any>;
seq?: number;
};
interface WSManagerOptions {
skipStreamEvents?: boolean;
// Session-scoped WSes opt into resume + connection-state dispatches
// by passing this. Dashboard WS doesn't.
sessionId?: string;
}
// Heartbeat tuning. 25s is below typical aggressive NAT idle timeouts
// (some enterprise firewalls drop after 30s of silence), and well
// below browser-tab background throttling thresholds. 10s pong
// timeout is a balance: long enough to tolerate flaky cellular RTT
// spikes, short enough that a real dead socket reconnects fast.
const HEARTBEAT_INTERVAL_MS = 25_000;
const HEARTBEAT_TIMEOUT_MS = 10_000;
interface QueuedFrame {
event: string;
data: Record<string, any>;
// Lets the future server-side dedup index match retries to
// originals. Today the server treats most events idempotently
// anyway (stop on stopped is a no-op), but the client sends this
// forward-compatibly so a future server upgrade is safe without a
// protocol bump.
client_msg_id: string;
}
class WebSocketManager {
private ws: WebSocket | null = null;
private url: string;
private skipStreamEvents: boolean;
private sessionId: string | null;
// Resume state. lastSeq is the highest server-assigned seq this
// client has applied; it's sent on every (re)connect so the server
// can replay missed events. Persists for the lifetime of this
// WebSocketManager instance , when the user navigates away and a
// new createSessionWs() is constructed, lastSeq starts at 0 and we
// get a full replay.
private connectionUuid: string;
private lastSeq: number = 0;
private resumeAcked: boolean = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
// Set to true by `disconnect()` so we don't reconnect after an
// explicit close (component unmount / user clicks Close).
private explicitlyClosed: boolean = false;
// Heartbeat. We send a ping on a fixed cadence and arm a timeout
// for the pong; if the timeout fires, we force-close the socket so
// `onclose` triggers reconnect. Detects laptop-sleep / NAT-drop
// silent failures that wouldn't otherwise surface until the next
// outbound send.
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pongTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
// Outbound queue. Frames the user enqueues while the WS isn't
// OPEN , or while OPEN but pre-resume-ack , wait here and flush
// after the resume handshake completes. Queue is in-memory only:
// surviving a full app restart isn't worth the localStorage
// complexity given how rare that case is for a transient drop.
private outboundQueue: QueuedFrame[] = [];
private listeners: Map<string, Set<(data: any) => void>> = new Map();
// Frame-aligned message coalescer. Buffers incoming WS messages from
// all WebSocketManager instances and flushes them in ONE batched
// React render per animation frame. Without this, N concurrent agents
// each cause their own renders on every WS message , dozens of full
// app re-renders per second, fanning out to every useSelector. With
// it: max one render per frame regardless of message volume.
private static _messageQueue: Array<{ mgr: WebSocketManager; msg: WSEvent }> = [];
private static _flushScheduled = false;
private static _enqueueMessage(mgr: WebSocketManager, msg: WSEvent) {
WebSocketManager._messageQueue.push({ mgr, msg });
if (WebSocketManager._flushScheduled) return;
WebSocketManager._flushScheduled = true;
requestAnimationFrame(WebSocketManager._flushMessages);
}
private static _flushMessages = () => {
WebSocketManager._flushScheduled = false;
if (WebSocketManager._messageQueue.length === 0) return;
const batch = WebSocketManager._messageQueue;
WebSocketManager._messageQueue = [];
// unstable_batchedUpdates collapses all dispatches inside the
// callback into a single React render. Available in React 17;
// React 18's automatic batching covers this too, but explicit
// wrap remains correct in both and protects against future
// batching-context changes.
unstable_batchedUpdates(() => {
for (const { mgr, msg } of batch) {
try {
mgr.handleMessage(msg);
} catch (e) {
console.warn('[ws] message handler threw', e);
}
}
});
};
constructor(url: string, options?: WSManagerOptions) {
this.url = url;
this.skipStreamEvents = options?.skipStreamEvents ?? false;
this.sessionId = options?.sessionId ?? null;
this.connectionUuid = _genUuid();
// Seed lastSeq from the cross-mount persistent map so a fresh
// manager (created on every AgentChat remount via key={session.id})
// doesn't ask the server to replay events the previous manager
// already saw. This is the architectural fix for "completed chats
// re-type themselves on reopen": the server's resume protocol now
// sees a real high-water mark and has nothing to replay.
if (this.sessionId) {
this.lastSeq = _sessionLastSeq.get(this.sessionId) ?? 0;
}
}
// Tokens render as they arrive (claude.ai feel). Per-frame WS batching
// in _enqueueMessage still coalesces N concurrent agents' messages into
// ONE React render per animation frame, so removing the pacing layer
// doesn't reintroduce the parallel-agent re-render storm.
private dispatchDelta(sessionId: string, messageId: string, delta: string) {
store.dispatch(streamDelta({ sessionId, messageId, delta }));
}
connect() {
if (this.ws?.readyState === WebSocket.OPEN) return;
this.explicitlyClosed = false;
// Append our per-install auth token to the URL. The backend's WS
// handshake validates this before accepting; without it, any
// webpage loaded on the same machine could open a WS and read
// agent traffic. See backend/auth.py + main.py:_ws_auth_ok.
const token = _getAuthTokenSafe();
const sep = this.url.includes('?') ? '&' : '?';
const urlWithToken = token ? `${this.url}${sep}token=${encodeURIComponent(token)}` : this.url;
// eslint-disable-next-line no-console
console.log('[diag][ws] connect', this.url, 'sessionId=', this.options.sessionId, 'hasToken=', !!token);
this.ws = new WebSocket(urlWithToken);
this.ws.onopen = () => {
this.reconnectDelay = 1000;
this.resumeAcked = false;
this.startHeartbeat();
// Send hello immediately so the server can replay anything the
// server sent that we never applied. On a fresh session,
// last_seq=0 → server replays from buffer start (empty) and
// we proceed normally.
if (this.sessionId) {
this.sendRaw('client:hello', {
session_id: this.sessionId,
connection_uuid: this.connectionUuid,
last_seq: this.lastSeq,
});
} else {
// Dashboard / global WS: no resume, queue can flush right away.
this.resumeAcked = true;
this.flushQueue();
}
};
this.ws.onmessage = (event) => {
try {
const msg: WSEvent = JSON.parse(event.data);
// Buffer incoming messages and flush them per animation frame
// in a single React batch. With N concurrent agents/browsers
// streaming, each WS instance used to trigger its own React
// render , dozens per frame, fanning out to every useSelector
// subscriber, starving the main thread. Coalescing flips that
// to ONE batched render per frame regardless of how many
// messages arrived. Stream deltas dispatch directly into Redux
// (no client-side pacing), so the typed-text rate matches what
// the server sends, the same way claude.ai feels.
WebSocketManager._enqueueMessage(this, msg);
} catch {
// ignore malformed messages
}
};
this.ws.onclose = (ev) => {
this.stopHeartbeat();
// 4401 = our backend's auth-failure code. Happens on stale token
// after backend restart (dev hot-reload). Re-fetch from Electron
// IPC before retrying.
if (ev && ev.code === 4401) {
import('@/shared/config').then(mod => mod.refreshAuthToken().catch(() => {}));
}
// Mark UI as reconnecting so the run card shows a clear
// "trying to reconnect" state rather than implying the run
// died. Skipped on an explicit disconnect (user navigated
// away) since there's no run to surface state for.
if (this.sessionId && !this.explicitlyClosed) {
store.dispatch(setSessionConnState({
sessionId: this.sessionId,
state: 'reconnecting',
}));
}
if (!this.explicitlyClosed) this.scheduleReconnect();
};
this.ws.onerror = () => {
// Force the close path to run , onclose will mark state
// reconnecting and schedule a retry.
this.ws?.close();
};
}
disconnect() {
this.explicitlyClosed = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat();
this.ws?.close();
this.ws = null;
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
// No retry cap. Long-horizon agent runs may outlast a multi-hour
// network outage (overnight laptop sleep, captive portal limbo);
// giving up would silently desync the UI. Backoff is bounded at
// 30s so the user-visible "Reconnecting…" loop never hammers the
// network, and a small jitter prevents thundering-herd if many
// session WSes reconnect at once after a backend restart.
const jitter = 0.8 + Math.random() * 0.4; // ±20%
const delay = Math.min(this.reconnectDelay, this.maxReconnectDelay) * jitter;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
this.connect();
}, delay);
}
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
this.sendPing();
}, HEARTBEAT_INTERVAL_MS);
}
private stopHeartbeat() {
if (this.heartbeatTimer != null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.pongTimeoutTimer != null) {
clearTimeout(this.pongTimeoutTimer);
this.pongTimeoutTimer = null;
}
}
private sendPing() {
if (this.ws?.readyState !== WebSocket.OPEN) return;
const nonce = _genUuid();
try {
this.ws.send(JSON.stringify({ event: 'client:ping', data: { nonce } }));
} catch {
// socket dying , let the close handler take over
return;
}
if (this.pongTimeoutTimer != null) clearTimeout(this.pongTimeoutTimer);
this.pongTimeoutTimer = setTimeout(() => {
// Silent death: no pong arrived in time. Force a close so the
// browser's onclose path (and our reconnect) runs immediately
// instead of waiting for the OS TCP keepalive (~75s).
try { this.ws?.close(); } catch { /* nothing */ }
}, HEARTBEAT_TIMEOUT_MS);
}
private clearPongTimeout() {
if (this.pongTimeoutTimer != null) {
clearTimeout(this.pongTimeoutTimer);
this.pongTimeoutTimer = null;
}
}
private flushQueue() {
if (this.ws?.readyState !== WebSocket.OPEN) return;
if (!this.resumeAcked) return;
const queue = this.outboundQueue;
this.outboundQueue = [];
for (const frame of queue) {
try {
this.ws.send(JSON.stringify({ event: frame.event, data: frame.data }));
} catch {
// Re-queue and bail; reconnect will retry.
this.outboundQueue.unshift(frame);
break;
}
}
}
// Direct send that bypasses the queue. Used for hello/ping which
// must NOT be queued (they're connection-scoped, not session-data).
private sendRaw(event: string, data: Record<string, any>) {
if (this.ws?.readyState !== WebSocket.OPEN) return;
try { this.ws.send(JSON.stringify({ event, data })); } catch { /* nothing */ }
}
private handleMessage(msg: WSEvent) {
const { event, session_id, data } = msg;
// Update lastSeq for events that carry one. seq is monotonic per
// session, so this is the high-water mark we send back on resume.
if (typeof msg.seq === 'number' && msg.seq > this.lastSeq) {
this.lastSeq = msg.seq;
// Mirror to the module-scope persistent map so the next fresh
// manager (next AgentChat remount) starts here, not at zero.
if (this.sessionId) {
_sessionLastSeq.set(this.sessionId, this.lastSeq);
}
}
// ----- Connection-scoped frames (no business-logic side effects) -----
if (event === 'server:pong') {
this.clearPongTimeout();
return;
}
if (event === 'server:hello') {
// Resume handshake completed. The server has either replayed
// missed events (which arrived as separate frames before this
// ack), surfaced a gap, or signalled "you're caught up." Mark
// ourselves live and flush any queued outbound frames.
this.resumeAcked = true;
if (this.sessionId) {
store.dispatch(setSessionConnState({
sessionId: this.sessionId,
state: 'live',
}));
}
this.flushQueue();
return;
}
if (event === 'agent:gap_detected') {
// We were offline long enough that the server's ring buffer
// rolled past our lastSeq. Re-fetch authoritative state via
// REST so the slice's view doesn't have a silent gap.
if (session_id) {
store.dispatch(fetchSession(session_id));
// Reset lastSeq , the REST refetch is the new authoritative
// baseline; subsequent server events with seq numbers will
// re-establish the high-water mark. Also wipe the cross-mount
// persistent map so a remount during this gap window doesn't
// resurrect the stale value.
this.lastSeq = 0;
_sessionLastSeq.delete(session_id);
}
return;
}
if (this.skipStreamEvents) {
if (event === 'agent:stream_start' || event === 'agent:stream_delta' || event === 'agent:stream_end') {
return;
}
}
switch (event) {
case 'agent:status':
// Capture pre-transition status so we only fire a system notification
// on a real running→terminal transition. Otherwise a session that
// was already 'completed' on disk and got refetched would re-toast.
{
const prevSession = session_id ? store.getState().agents.sessions[session_id] : undefined;
const prevStatus = prevSession?.status;
if (data.session) {
store.dispatch(updateSession(data.session));
} else if (session_id) {
store.dispatch(updateSessionStatus({ sessionId: session_id, status: data.status }));
}
if (data.status === 'running' && session_id) {
store.dispatch(trackAgentNotification(session_id));
}
// Fire a native notification when an agent terminates while the
// window is hidden. Skips sub-agents and browser-agents (the
// parent's own completion is what the user cares about) and only
// fires on a real transition from a non-terminal state.
const TERMINAL = new Set(['completed', 'error']);
const NON_TERMINAL = new Set(['running', 'waiting_approval', undefined, null, '']);
if (
session_id &&
TERMINAL.has(data.status) &&
NON_TERMINAL.has(prevStatus as any) &&
data.session?.mode !== 'browser-agent' &&
data.session?.mode !== 'sub-agent' &&
data.session?.mode !== 'invoked-agent'
) {
const sess = data.session ?? prevSession;
if (sess) {
const lastAssistant = [...(sess.messages || [])]
.reverse()
.find((m: any) => m.role === 'assistant' && typeof m.content === 'string');
notifyAgentCompletion({
sessionId: session_id,
sessionName: sess.name || 'Agent',
dashboardId: sess.dashboard_id,
status: data.status as 'completed' | 'error',
bodyExcerpt: lastAssistant ? String(lastAssistant.content) : undefined,
});
}
}
// Clear any leftover turn label when the agent reaches a
// terminal state so the next turn doesn't show a stale label
// before its own aux call lands.
if (session_id && (data.status === 'completed' || data.status === 'error' || data.status === 'stopped')) {
store.dispatch(clearTurnLabel(session_id));
}
}
// Per-sub-agent close via browser_id; skip user-created cards (no spawned_by).
if (
(data.status === 'completed' || data.status === 'error') &&
data.session?.mode === 'browser-agent'
) {
const browserId = data.session.browser_id;
if (browserId) {
const card = store.getState().dashboardLayout.browserCards[browserId];
if (card && card.spawned_by) {
store.dispatch(removeBrowserCard(browserId));
}
}
}
break;
case 'agent:message':
if (session_id && data.message) {
store.dispatch(addMessage({ sessionId: session_id, message: data.message }));
}
break;
case 'agent:output_upserted':
// Emitted by the backend when an Output row is created (canvas-launched
// App Builder seed) or updated (post-session meta.json sync). The
// upsert reducer merges over an existing row so a UI that already
// loaded the row doesn't lose locally-applied fields.
if (data.output && data.output.id) {
store.dispatch(upsertOutput(data.output));
}
break;
case 'agent:stream_start':
case 'agent:stream_delta':
case 'agent:stream_end':
// Replay-skip guard. The WS resume protocol replays buffered
// events from the ring buffer with seq > last_seq. When this
// manager is freshly constructed (every AgentChat mount,
// because of `key={session.id}`), last_seq is 0, so the server
// replays EVERY buffered stream_* event for the session.
// Without this guard, opening any chat with prior streaming
// turns would replay every buffered delta as a live stream
// event, re-triggering the streaming UI on every reopen.
//
// The discriminator is `resumeAcked`: it flips to true when
// server:hello arrives, which the server sends AFTER the replay
// completes. Any stream_* event arriving while !resumeAcked is
// replay-from-buffer (historical) and can be dropped , the REST
// snapshot we awaited before connect is authoritative for any
// already-finalized message, and any genuinely live turn the
// server is pushing will continue emitting events after the ack.
if (!this.resumeAcked) break;
if (event === 'agent:stream_start') {
if (session_id && data.message_id) {
store.dispatch(streamStart({
sessionId: session_id,
messageId: data.message_id,
role: data.role,
toolName: data.tool_name,
}));
}
} else if (event === 'agent:stream_delta') {
if (session_id && data.message_id) {
this.dispatchDelta(session_id, data.message_id, data.delta);
}
} else if (event === 'agent:stream_end') {
if (session_id && data.message_id) {
store.dispatch(streamEnd({
sessionId: session_id,
messageId: data.message_id,
}));
}
}
break;
case 'agent:approval_request':
if (session_id) {
store.dispatch(addApprovalRequest({
sessionId: session_id,
request: {
id: data.request_id,
session_id: session_id,
tool_name: data.tool_name,
tool_input: data.tool_input,
created_at: new Date().toISOString(),
sensitive_pattern: data.sensitive_pattern ?? null,
sensitive_label: data.sensitive_label ?? null,
sensitive_why: data.sensitive_why ?? null,
},
}));
}
break;
case 'agent:cost_update':
if (session_id) {
store.dispatch(updateSessionCost({
sessionId: session_id,
costUsd: data.cost_usd,
}));
}
break;
case 'agent:context_update':
if (session_id) {
store.dispatch(updateSessionContext({
sessionId: session_id,
inputTokens: data.input_tokens ?? 0,
outputTokens: data.output_tokens ?? 0,
cacheReadTokens: data.cache_read_tokens ?? 0,
cacheReadPct: data.cache_read_pct ?? 0,
ctxUsedPct: data.ctx_used_pct ?? 0,
contextWindow: typeof data.context_window === 'number' ? data.context_window : undefined,
frameworkOverheadTokens: typeof data.framework_overhead_tokens === 'number' ? data.framework_overhead_tokens : undefined,
activeMcps: Array.isArray(data.active_mcps) ? data.active_mcps : [],
}));
}
break;
case 'agent:context_overflow':
if (session_id) {
store.dispatch(setContextOverflow({
sessionId: session_id,
reason: data.reason ?? 'long_context_required',
message: data.message ?? 'Context full.',
}));
}
break;
case 'agent:context_status':
// Auto-compaction collapsed older turns into a summary. Mirror
// compacted_through_msg_id locally so the renderer can drop a
// visible "N earlier turns summarized" chip into the transcript.
// Other reasons (cleared, etc.) flow through this same event but
// don't currently need a chip , ignore them for now.
if (session_id && data.reason === 'compacted') {
store.dispatch(recordCompaction({
sessionId: session_id,
throughMsgId: data.compacted_through_msg_id ?? null,
}));
}
break;
case 'agent:turn_label':
// Aux-LLM-generated verb-phrase for the current turn. Replaces
// the static "Thinking…" label until the turn ends, then the
// ThinkingBubble freezes to "Thought for Ns · M tokens".
if (session_id && data.label) {
store.dispatch(setTurnLabel({
sessionId: session_id,
turnId: data.turn_id || '',
label: data.label,
}));
}
break;
case 'agent:auth_error':
// Re-uses the context_overflow card slot , both are "this session is
// blocked, here's what to do" cards. Reason field disambiguates.
if (session_id) {
store.dispatch(setContextOverflow({
sessionId: session_id,
reason: data.reason ?? 'auth_error',
message: data.message ?? 'Authentication failed.',
}));
}
break;
case 'agent:mcp_suggestions':
if (session_id) {
store.dispatch(setMcpSuggestions({
sessionId: session_id,
suggestions: Array.isArray(data.suggestions) ? data.suggestions : [],
isVague: !!data.is_vague,
}));
}
break;
case 'agent:branch_created':
if (session_id && data.branch) {
store.dispatch(addBranch({ sessionId: session_id, branch: data.branch }));
store.dispatch(setActiveBranch({ sessionId: session_id, branchId: data.active_branch_id }));
}
break;
case 'agent:branch_switched':
if (session_id) {
store.dispatch(setActiveBranch({ sessionId: session_id, branchId: data.active_branch_id }));
}
break;
case 'agent:name_updated':
if (session_id && data.name) {
store.dispatch(updateSessionName({ sessionId: session_id, name: data.name }));
}
break;
case 'agent:group_meta_updated':
if (session_id && data.group_id) {
store.dispatch(updateGroupMeta({
sessionId: session_id,
groupId: data.group_id,
name: data.name ?? '',
svg: data.svg ?? '',
isRefined: data.is_refined ?? false,
}));
}
break;
case 'agent:closed':
if (session_id) {
const closedStatus = data.status ?? 'stopped';
store.dispatch(closeSessionFromWs({
id: session_id,
name: data.name ?? 'Untitled',
status: closedStatus,
model: data.model ?? '',
mode: data.mode ?? '',
created_at: data.created_at ?? new Date().toISOString(),
closed_at: data.closed_at ?? new Date().toISOString(),
cost_usd: data.cost_usd ?? 0,
dashboard_id: data.dashboard_id,
}));
// Auto-delete browsers spawned by this agent when it finishes
// normally or errors out. We intentionally skip 'stopped' , the
// user may want to inspect the browser after manually stopping.
if (closedStatus === 'completed' || closedStatus === 'error') {
const browserCards = store.getState().dashboardLayout.browserCards;
for (const card of Object.values(browserCards)) {
if (card.spawned_by === session_id) {
store.dispatch(removeBrowserCard(card.browser_id));
}
}
}
}
break;
case 'workflow:run':
if (data.run) {
store.dispatch(upsertRun(data.run));
}
break;
case 'workflow:notify':
try {
notifyAgentCompletion({
sessionId: data.session_id || data.workflow_id,
sessionName: data.workflow_title || 'Workflow',
status: data.status === 'success' ? 'completed' : 'error',
});
} catch { /* notifications are best-effort */ }
try {
const w: any = (window as any).openswarm;
if (w?.notify) {
// Seed by workflow id + current minute so multiple workflows pick different copy
// while a single workflow stays stable within a few minutes.
const seed = ((data.workflow_id || '').length + Math.floor(Date.now() / 60000)) | 0;
const SUCCESS_TITLES = [
`${data.workflow_title || 'Workflow'} — done`,
`${data.workflow_title || 'Workflow'} just wrapped up`,
`Heads up: ${data.workflow_title || 'Workflow'} finished`,
`${data.workflow_title || 'Workflow'} is ready`,
];
const FAILURE_TITLES = [
`${data.workflow_title || 'Workflow'} hit a snag`,
`${data.workflow_title || 'Workflow'} couldn't finish`,
`Something went sideways on ${data.workflow_title || 'Workflow'}`,
];
const LATE_TITLES = [
`${data.workflow_title || 'Workflow'} caught up late`,
`${data.workflow_title || 'Workflow'} ran late but made it`,
];
const pool = data.status === 'success' ? SUCCESS_TITLES
: data.status === 'failure' ? FAILURE_TITLES
: data.status === 'ran_late' ? LATE_TITLES
: [`${data.workflow_title || 'Workflow'}${data.status}`];
const title = pool[Math.abs(seed) % pool.length];
const isMac = (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
const body = data.tier_kind && data.fallback
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
: data.status === 'success'
? (isMac ? 'Tap to see what it did.' : 'Click to see what it did.')
: data.status === 'failure'
? (isMac ? 'Tap to see what went wrong.' : 'Click to see what went wrong.')
: (isMac ? 'Tap to open the run.' : 'Click to open the run.');
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
const actions = [
{ text: 'Looks good', outcome: 'ack' },
{ text: 'Re-run', outcome: 'rerun' },
{ text: 'Adjust', outcome: 'edit' },
];
w.notify({ title, body, deepLink, runId: data.run_id, workflowId: data.workflow_id, actions });
}
} catch { /* native notif optional */ }
break;
case 'dashboard:browser_card_added':
if (data.browser_card) {
store.dispatch(addBrowserCardFromBackend(data.browser_card));
const parentId = data.parent_session_id;
if (parentId) {
const layoutState = store.getState().dashboardLayout;
const parentCard = layoutState.cards[parentId];
if (parentCard) {
const targetX = parentCard.x + parentCard.width + GRID_GAP * 12;
let targetY = parentCard.y;
const columnCards = Object.values(layoutState.browserCards).filter(
(c) => Math.abs(c.x - targetX) < 50 && c.browser_id !== data.browser_card.browser_id,
);
if (columnCards.length > 0) {
const lowestBottom = Math.max(...columnCards.map((c) => c.y + c.height));
targetY = lowestBottom + GRID_GAP;
}
store.dispatch(setBrowserCardPosition({
browserId: data.browser_card.browser_id,
x: targetX,
y: targetY,
}));
store.dispatch(setGlowingBrowserCards({
browserIds: [data.browser_card.browser_id],
sessionId: parentId,
label: 'Use Browser',
}));
}
}
}
break;
}
// Notify any custom listeners
const handlers = this.listeners.get(event);
if (handlers) {
handlers.forEach((fn) => fn({ session_id, ...data }));
}
}
send(event: string, data: Record<string, any>) {
// Queue if the socket isn't open OR resume hasn't been ack'd yet.
// The pre-ack gate prevents an outbound user message from racing
// the resume replay , the server might process the message
// before the replay finishes, leaving the slice's view of
// history incomplete.
const open = this.ws?.readyState === WebSocket.OPEN;
if (!open || !this.resumeAcked) {
this.outboundQueue.push({ event, data, client_msg_id: _genUuid() });
return;
}
try {
this.ws!.send(JSON.stringify({ event, data }));
} catch {
this.outboundQueue.push({ event, data, client_msg_id: _genUuid() });
}
}
sendMessage(
sessionId: string,
prompt: string,
opts?: { mode?: string; model?: string; provider?: string; images?: Array<{ data: string; media_type: string }> },
) {
this.send('agent:send_message', {
session_id: sessionId,
prompt,
...opts,
});
}
sendApproval(requestId: string, behavior: 'allow' | 'deny', message?: string) {
this.send('agent:approval_response', {
request_id: requestId,
behavior,
message,
});
}
stopAgent(sessionId: string) {
this.send('agent:stop', { session_id: sessionId });
}
on(event: string, handler: (data: any) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
return () => this.listeners.get(event)?.delete(handler);
}
get connected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
}
import { WS_BASE } from '@/shared/config';
// Bridge native-notification button actions to workflow actions. Subscribe at module
// import time so we never miss an early callback fired before any component mounts.
(() => {
try {
const w: any = (typeof window !== 'undefined') ? (window as any).openswarm : null;
if (!w?.onNotificationAction) return;
w.onNotificationAction(({ outcome, runId, workflowId }: { outcome: string; runId?: string; workflowId?: string }) => {
if (!workflowId) return;
if (outcome === 'ack' && runId) {
store.dispatch(ackRun(runId));
return;
}
if (outcome === 'rerun') {
store.dispatch(runWorkflowNow(workflowId));
return;
}
if (outcome === 'edit' || outcome === 'open') {
store.dispatch(addWorkflowCard({ workflowId }));
store.dispatch(openWorkflowCard({ workflowId, view: outcome === 'edit' ? 'edit' : 'saved', editFacet: outcome === 'edit' ? 'Schedule' : undefined }));
return;
}
});
} catch { /* native notifications optional */ }
})();
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
// Per-session high-water mark for the resume protocol. Survives across
// AgentChat mounts/unmounts so reopening a chat doesn't re-trigger a
// full replay from the server's ring buffer.
//
// Why this exists: AgentChat uses `key={session.id}` on the embedded
// instance inside AgentCard, so every expand/collapse remounts the
// component, which constructs a fresh WebSocketManager. Without this
// persistent map, each fresh manager starts at last_seq=0 and asks the
// server for the entire buffered history. The server faithfully
// replays it, the client renders the typewriter animation again, and
// the user sees their completed chat "type itself out" on every reopen.
//
// Lifetime: tied to the JS module load, which means the page tab. Lost
// on full app reload (intentional , that should re-hydrate from REST).
// On backend restart the buffers are wiped anyway, so a stale
// lastSeq pointing past the buffer top falls into the "fresh client"
// path on the server (last_seq>0 but no buffer) which short-circuits
// to a no-op replay. Safe.
const _sessionLastSeq: Map<string, number> = new Map();
export function createSessionWs(sessionId: string): WebSocketManager {
return new WebSocketManager(`${WS_BASE}/ws/agents/${sessionId}`, { sessionId });
}
export default WebSocketManager;