Files
Control-Center/frontend/src/components/Layout.tsx
Joshua 23f9d4a8fb CUB-125: implement real-time SSE/WebSocket in React frontend
- 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 <noreply@anthropic.com>
2026-05-20 12:58:21 -04:00

143 lines
5.5 KiB
TypeScript

import { useState } from 'react'
import { NavLink } from 'react-router-dom'
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' },
{ to: '/logs', icon: Activity, label: 'Logs' },
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
{ to: '/sessions', icon: Monitor, label: 'Sessions' },
{ 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 (
<div className="flex items-center gap-1.5" title={cfg.label}>
<Icon size={14} className={cfg.color} />
{showLabel && <span className={`text-xs ${cfg.color}`}>{cfg.label}</span>}
</div>
)
}
export default function Layout({ children }: { children: React.ReactNode }) {
const [expanded, setExpanded] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const { sseStatus } = useSSEContext()
return (
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
{/* Desktop Nav Rail */}
<aside
className={`hidden md:flex flex-col border-r border-surface-light transition-all duration-200 ${
expanded ? 'w-64' : 'w-18'
}`}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
>
<div className="flex items-center gap-3 px-4 h-16 border-b border-surface-light">
<Command size={24} className="text-primary shrink-0" />
{expanded && <span className="font-bold text-lg whitespace-nowrap">Control Center</span>}
</div>
<nav className="flex-1 py-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 mx-2 rounded-lg transition-colors ${
isActive
? 'bg-primary/10 text-primary'
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
}`
}
>
<item.icon size={20} className="shrink-0" />
{expanded && <span className="whitespace-nowrap">{item.label}</span>}
</NavLink>
))}
</nav>
{/* SSE connection status — footer of sidebar */}
<div className="px-4 py-3 border-t border-surface-light flex items-center gap-2">
<SSEStatusBadge status={sseStatus} />
{expanded && (
<span className="text-xs text-on-surface-muted whitespace-nowrap">
{sseStatus === 'connected' ? 'Live updates on' : sseStatus}
</span>
)}
</div>
</aside>
{/* Mobile Header + Bottom Nav */}
<div className="flex-1 flex flex-col md:ml-0">
<header className="md:hidden flex items-center justify-between h-16 px-4 border-b border-surface-light bg-surface-dark">
<div className="flex items-center gap-2">
<Command size={22} className="text-primary" />
<span className="font-bold">Control Center</span>
<SSEStatusBadge status={sseStatus} />
</div>
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
</button>
</header>
{/* Mobile drawer */}
{mobileOpen && (
<div className="md:hidden fixed inset-0 z-50 bg-surface-dark/95 backdrop-blur">
<div className="flex flex-col p-4 space-y-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg ${
isActive
? 'bg-primary/10 text-primary'
: 'text-on-surface-variant'
}`
}
>
<item.icon size={20} />
{item.label}
</NavLink>
))}
</div>
</div>
)}
<main className="flex-1 p-4 md:p-6 overflow-auto">{children}</main>
{/* Mobile Bottom Nav */}
<nav className="md:hidden flex items-center justify-around h-16 border-t border-surface-light bg-surface-dark">
{navItems.slice(0, 5).map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex flex-col items-center gap-1 p-2 text-xs ${
isActive ? 'text-primary' : 'text-on-surface-variant'
}`
}
>
<item.icon size={20} />
<span>{item.label}</span>
</NavLink>
))}
</nav>
</div>
</div>
)
}