Files
fusion/packages/dashboard/app/hooks/useRemoteNodeEvents.ts
gsxdsm f5163d8351 fix(dashboard): resync SSE state on reopen and bound the service-worker cache
Follow-up to f157bf7460, fixing the regressions an adversarial review found in
the mobile tab-discard work.

- SSE hidden-suspend dropped events silently: the per-task/run log streams are
  live-only with no replay, and many subscribers had no onReconnect, so a 60s+
  hidden window left invisible gaps in logs, a never-rendered approval banner,
  a diverged chat transcript, and a missed merge advance notice. Every
  subscriber now resyncs authoritative state on reopen.
- useAgentLogs refetches its authoritative page on reconnect and reports
  hasMore truthfully once paging reaches the first entry.
- Agent run logs are windowed rather than discarded, so the head of a long run
  stays reachable.
- lastFetchTimeMs is seeded from the cached envelope's savedAt, so a hydrated
  stale snapshot no longer renders every in-progress card as stuck.
- MAX_IMMUTABLE_CACHE_ENTRIES lands as 200; it was committed as Infinity, which
  left the cache-first bucket unbounded and the cap dead code.
- useAgentLogs.ts held a literal NUL byte that made git treat the file as binary
  and grep skip it; replaced with an escape sequence so it stays reviewable.

Verified: tsc -p tsconfig.app.json clean, pnpm lint clean, pnpm check:changesets
clean, 25 scoped test files / 1337 tests passing. The xterm scrollback constants
and several components still lacking onReconnect remain untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:13:39 -07:00

76 lines
2.7 KiB
TypeScript

/**
* useRemoteNodeEvents hook - subscribes to SSE events from a remote node via the proxy.
*/
import { useEffect, useState } from "react";
import { subscribeSse } from "../sse-bus";
export interface RemoteNodeEvent {
type: string;
data: unknown;
}
export interface UseRemoteNodeEventsResult {
/** Whether the SSE connection is currently active */
isConnected: boolean;
/** The last received event, or null if no events received */
lastEvent: RemoteNodeEvent | null;
}
/**
* Hook for subscribing to SSE events from a remote node via the proxy.
* Uses the shared SSE bus for connection multiplexing, heartbeat, and reconnection.
*/
export function useRemoteNodeEvents(nodeId: string | null): UseRemoteNodeEventsResult {
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<RemoteNodeEvent | null>(null);
useEffect(() => {
if (!nodeId) {
setIsConnected(false);
setLastEvent(null);
return;
}
const url = `/api/proxy/${encodeURIComponent(nodeId)}/events`;
return subscribeSse(url, {
events: {
"task:created": (e: MessageEvent) => {
setLastEvent({ type: "task:created", data: e.data });
},
"task:moved": (e: MessageEvent) => {
setLastEvent({ type: "task:moved", data: e.data });
},
"task:updated": (e: MessageEvent) => {
setLastEvent({ type: "task:updated", data: e.data });
},
// FN-5135: dashboard treats deletedAt-bearing task events as delete-equivalent,
// so task:deleted remains a load-bearing remote signal for cross-node refreshes.
"task:deleted": (e: MessageEvent) => {
setLastEvent({ type: "task:deleted", data: e.data });
},
"task:merged": (e: MessageEvent) => {
setLastEvent({ type: "task:merged", data: e.data });
},
},
onOpen: () => setIsConnected(true),
onError: () => setIsConnected(false),
/*
FNXC:RemoteNodeEvents 2026-07-26-15:10:
Explicit opt-out of the sse-bus resync contract. This hook is a pure relay: it holds no
accumulated state a missed event could corrupt — only `isConnected` (re-derived by onOpen/
onError) and `lastEvent`, which is a transient "something changed on the remote node" nudge
with no proxy endpoint to refetch. Consumers that need authoritative remote state must fetch it
themselves; if this hook ever accumulates a list or count, it needs a real onReconnect instead.
*/
replaySafe: { reason: "relay-only: no accumulated state, isConnected re-derived on open" },
});
}, [nodeId]);
return {
isConnected,
lastEvent,
};
}