forked from molecule-ai/molecule-core
Both AgentCommsPanel and ChatTab's activity-feed opened raw
`new WebSocket(WS_URL)` instances per mount, with no onclose handler
and no reconnect logic. When the underlying connection dropped — idle
timeout, browser background-tab throttle, network jitter — the per-
panel sockets stayed dead until the panel re-mounted (refresh or
sub-tab unmount/remount). Live agent-comms bubbles and live activity
feed lines silently went missing in the gap, manifesting as "the
delegation didn't show up until I refreshed."
The global ReconnectingSocket in store/socket.ts already owns
reconnect, exponential backoff, health-check, and HTTP fallback poll.
Routing component subscribers through it gives every consumer those
guarantees for free, with one TCP connection per tab instead of N.
Three new pieces:
- store/socket-events.ts: tiny pub/sub bus. emitSocketEvent fan-outs
every decoded WSMessage to the listener Set; subscribeSocketEvents
returns an unsubscribe. A throwing listener is logged and isolated
so it can't break siblings.
- store/socket.ts: ws.onmessage now calls emitSocketEvent(msg) right
after applyEvent(msg), so the store's derived state and component
subscribers stay in lockstep on every event arrival.
- hooks/useSocketEvent.ts: React hook that registers exactly once
per mount, capturing the latest handler in a ref so the closure
sees current state/props without re-subscribing on every render.
Refactored sites:
- AgentCommsPanel: replaced its WebSocket-in-useEffect block with
useSocketEvent. Same parsing logic; the panel no longer opens its
own connection.
- ChatTab activity feed: split the previous useEffect in two — one
seeds the activity log when `sending` flips, the other subscribes
unconditionally and gates work on `sending` inside the handler.
Hooks can't be conditional, so the gate has to live in the body
rather than around the effect.
The ws-close graceful-close helper is no longer needed in either
site; the global socket owns its own teardown.
Tests: 6 new tests for the bus contract (single delivery, fan-out
order, unsubscribe, throwing-listener isolation, no-subscriber emit,
duplicate-subscribe Set semantics). All 27 existing socket tests
still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
37 lines
1.5 KiB
TypeScript
37 lines
1.5 KiB
TypeScript
/** React hook to subscribe to global WS events without opening a new
|
|
* WebSocket connection. Subscribers are routed through the singleton
|
|
* ReconnectingSocket in store/socket.ts so they inherit its
|
|
* reconnect, backoff, and HTTP fallback for free.
|
|
*
|
|
* Usage:
|
|
*
|
|
* useSocketEvent((msg) => {
|
|
* if (msg.workspace_id !== workspaceId) return;
|
|
* if (msg.event !== "ACTIVITY_LOGGED") return;
|
|
* // ... handle ...
|
|
* });
|
|
*
|
|
* The handler is captured into a ref on every render so the latest
|
|
* closure (with its current state / props) is always invoked, while
|
|
* the actual subscription is registered exactly once per mount.
|
|
* Without the ref, an inline-defined handler would re-subscribe on
|
|
* every render, churning Set add/delete and risking missed events
|
|
* during the gap.
|
|
*
|
|
* The handler is responsible for its own filtering — by event type,
|
|
* workspace_id, payload shape, etc. The bus is intentionally untyped
|
|
* beyond the WSMessage envelope; coupling each consumer to a typed
|
|
* per-event schema would defeat the "tiny pub/sub" goal. */
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import type { WSMessage } from "@/store/socket";
|
|
import { subscribeSocketEvents } from "@/store/socket-events";
|
|
|
|
export function useSocketEvent(handler: (msg: WSMessage) => void): void {
|
|
const handlerRef = useRef(handler);
|
|
handlerRef.current = handler;
|
|
useEffect(() => {
|
|
return subscribeSocketEvents((msg) => handlerRef.current(msg));
|
|
}, []);
|
|
}
|