From 5147997764688df62bbfdd7b05c3794e71f36b7d Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 13 May 2026 18:10:38 -0400 Subject: [PATCH] CUB-125: implement real-time SSE/WebSocket in React frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useSSE hook with exponential back-off reconnect (1s → 30s) - Add useRealtimeSync hook: maps SSE events to React Query invalidation (agent.status → agents; agent.task/agent.progress → tasks+agents; fleet.update → all) - Add SSEContext/SSEProvider so connection status is available app-wide - Mount SSEProvider in main.tsx inside QueryClientProvider (no polling) - Show live/connecting/reconnecting/disconnected badge in sidebar + mobile header - Update SettingsPage: replace polling interval UI with SSE status panel - Disable React Query polling (staleTime 60s); all updates pushed via SSE Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Layout.tsx | 34 +++++- frontend/src/contexts/SSEContext.tsx | 23 ++++ frontend/src/hooks/useRealtimeSync.ts | 52 +++++++++ frontend/src/hooks/useSSE.ts | 159 ++++++++++++++++++++++++++ frontend/src/main.tsx | 15 ++- frontend/src/pages/SettingsPage.tsx | 84 +++++++------- frontend/src/services/sse.ts | 55 +++++++++ 7 files changed, 377 insertions(+), 45 deletions(-) create mode 100644 frontend/src/contexts/SSEContext.tsx create mode 100644 frontend/src/hooks/useRealtimeSync.ts create mode 100644 frontend/src/hooks/useSSE.ts create mode 100644 frontend/src/services/sse.ts diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 34ad59c..76d61c1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { NavLink } from 'react-router-dom' -import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X } from 'lucide-react' +import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X, Wifi, WifiOff, Loader } from 'lucide-react' +import { useSSEContext } from '../contexts/SSEContext' +import type { SSEStatus } from '../hooks/useSSE' const navItems = [ { to: '/', icon: Command, label: 'Hub' }, @@ -10,9 +12,29 @@ const navItems = [ { to: '/settings', icon: Settings, label: 'Settings' }, ] +/** Small status pill shown in the sidebar footer and mobile header. */ +function SSEStatusBadge({ status, showLabel = false }: { status: SSEStatus; showLabel?: boolean }) { + const cfg = { + connected: { icon: Wifi, color: 'text-green-500', label: 'Live' }, + connecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Connecting' }, + reconnecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Reconnecting' }, + error: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, + }[status] + + const Icon = cfg.icon + + return ( +
+ + {showLabel && {cfg.label}} +
+ ) +} + export default function Layout({ children }: { children: React.ReactNode }) { const [expanded, setExpanded] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) + const { sseStatus } = useSSEContext() return (
@@ -46,6 +68,15 @@ export default function Layout({ children }: { children: React.ReactNode }) { ))} + {/* SSE connection status — footer of sidebar */} +
+ + {expanded && ( + + {sseStatus === 'connected' ? 'Live updates on' : sseStatus} + + )} +
{/* Mobile Header + Bottom Nav */} @@ -54,6 +85,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
Control Center +
- ))} +
+
+

SSE Connection

+

{sseInfo.description}

+ + {sseInfo.label} +
+

+ Endpoint: /api/events +  · Events: agent.status, agent.task, agent.progress, fleet.update +

+

+ Polling is disabled. All status updates are pushed from the server over a persistent SSE connection. + The client reconnects automatically with exponential back-off on drop. +

diff --git a/frontend/src/services/sse.ts b/frontend/src/services/sse.ts new file mode 100644 index 0000000..63e2afb --- /dev/null +++ b/frontend/src/services/sse.ts @@ -0,0 +1,55 @@ +/** + * SSE event payload types matching the Go backend (internal/handler/sse.go). + * + * Event format on the wire: + * event: + * data: + */ + +import type { AgentStatus } from '../types' + +/** agent.status — agent came online, went offline, changed state */ +export interface AgentStatusEvent { + agentId: string + status: AgentStatus + /** Optional human-readable reason (e.g. error message) */ + reason?: string +} + +/** agent.task — a task was assigned to or completed by an agent */ +export interface AgentTaskEvent { + agentId: string + taskId: string + title: string + action: 'assigned' | 'completed' | 'failed' +} + +/** agent.progress — incremental progress update for a running task */ +export interface AgentProgressEvent { + agentId: string + taskId: string + progress: number + /** Optional description of what is currently happening */ + message?: string +} + +/** + * fleet.update — bulk refresh of all agents (e.g. after a deployment). + * The backend may send partial or complete agent state. + */ +export interface FleetUpdateEvent { + /** ISO timestamp of when the snapshot was taken */ + timestamp: string + /** Number of agents in the fleet */ + agentCount: number +} + +/** Union of all SSE data payloads keyed by event type. */ +export type SSEPayloadMap = { + 'agent.status': AgentStatusEvent + 'agent.task': AgentTaskEvent + 'agent.progress': AgentProgressEvent + 'fleet.update': FleetUpdateEvent + connected: { clientCount: number } + message: unknown +}