/** * useRealtimeSync — mounts the SSE connection once at the app level and * wires incoming events to React Query cache invalidation. * * Event → query key mapping: * agent.status → ['agents'] * agent.task → ['tasks'], ['agents'] * agent.progress → ['tasks'], ['agents'] * fleet.update → ['agents'], ['sessions'], ['tasks'] */ import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { useSSE, type SSEStatus } from './useSSE' import type { SSEMessage } from '../services/sse' export function useRealtimeSync(): { sseStatus: SSEStatus } { const queryClient = useQueryClient() const handleMessage = useCallback( (raw: { type: string; data: unknown }) => { // Cast to discriminated union — the backend contract guarantees these shapes const msg = raw as SSEMessage switch (msg.type) { case 'agent.status': // msg.data: AgentStatusEvent { agentId, status, reason? } void msg.data.agentId // retained for type-narrowing — ensures payload matches contract queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'agent.task': // msg.data: AgentTaskEvent { agentId, taskId, title, action } void msg.data.agentId queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'agent.progress': // msg.data: AgentProgressEvent { agentId, taskId, progress, message? } void msg.data.agentId queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'fleet.update': // msg.data: FleetUpdateEvent { timestamp, agentCount } void msg.data.agentCount queryClient.invalidateQueries({ queryKey: ['agents'] }) queryClient.invalidateQueries({ queryKey: ['sessions'] }) queryClient.invalidateQueries({ queryKey: ['tasks'] }) break default: // 'connected' and unknown events — no action needed break } }, [queryClient], ) const { status: sseStatus } = useSSE({ onMessage: handleMessage }) return { sseStatus } }