From 8b8cb8210c92fe21928ee3208e2db1749d453d6f Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 8 May 2026 19:53:21 -0400 Subject: [PATCH] CUB-121: build React pages with real API integration - HubPage: agent summary stats, cards, status badges, progress bars, refresh - LogsPage: activity feed from tasks, status filter, loading skeleton - ProjectsPage: project cards with status badges and agent counts - SessionsPage: responsive table/card view with model/token info - SettingsPage: dark mode toggle, gateway URL, refresh interval persist - ThemeProvider with dark/light mode via CSS custom properties - useLocalStorage hook for settings persistence - Loading/error/empty states across all pages - npm run build passes cleanly --- frontend/src/hooks/useLocalStorage.ts | 29 ++++ frontend/src/hooks/useTheme.tsx | 50 ++++++ frontend/src/index.css | 47 ++++++ frontend/src/main.tsx | 13 +- frontend/src/pages/HubPage.tsx | 231 +++++++++++++++++++------- frontend/src/pages/LogsPage.tsx | 180 +++++++++++++++++++- frontend/src/pages/ProjectsPage.tsx | 115 ++++++++++++- frontend/src/pages/SessionsPage.tsx | 175 ++++++++++++++++++- frontend/src/pages/SettingsPage.tsx | 124 +++++++++++++- 9 files changed, 885 insertions(+), 79 deletions(-) create mode 100644 frontend/src/hooks/useLocalStorage.ts create mode 100644 frontend/src/hooks/useTheme.tsx diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..ecef14b --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState, useEffect, useCallback } from 'react' + +export function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key) + return item !== null ? (JSON.parse(item) as T) : initialValue + } catch { + return initialValue + } + }) + + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(storedValue)) + } catch { + // storage full or unavailable + } + }, [key, storedValue]) + + const setValue = useCallback((value: T | ((prev: T) => T)) => { + setStoredValue((prev) => { + const next = value instanceof Function ? value(prev) : value + return next + }) + }, []) + + return [storedValue, setValue] +} diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx new file mode 100644 index 0000000..6efa8c6 --- /dev/null +++ b/frontend/src/hooks/useTheme.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react' + +type Theme = 'dark' | 'light' + +interface ThemeContextValue { + theme: Theme + toggleTheme: () => void + isDark: boolean +} + +const ThemeContext = createContext(null) + +function getInitialTheme(): Theme { + if (typeof window === 'undefined') return 'dark' + const stored = localStorage.getItem('cc-theme') + if (stored === 'light' || stored === 'dark') return stored + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark' +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState(getInitialTheme) + + useEffect(() => { + const root = document.documentElement + if (theme === 'dark') { + root.classList.add('dark') + root.classList.remove('light') + } else { + root.classList.add('light') + root.classList.remove('dark') + } + localStorage.setItem('cc-theme', theme) + }, [theme]) + + const toggleTheme = useCallback(() => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')) + }, []) + + return ( + + {children} + + ) +} + +export function useTheme() { + const ctx = useContext(ThemeContext) + if (!ctx) throw new Error('useTheme must be used within ThemeProvider') + return ctx +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5bddcea..d73bca4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -18,6 +18,17 @@ --color-status-thinking: #A78BFA; --color-status-error: #F87171; --color-status-offline: #64748B; + + /* Light mode overrides */ + --color-light-surface-darkest: #F8FAFC; + --color-light-surface-dark: #F1F5F9; + --color-light-surface-medium: #E2E8F0; + --color-light-surface-light: #CBD5E1; + --color-light-surface-lighter: #94A3B8; + --color-light-on-surface: #0F172A; + --color-light-on-surface-variant: #475569; + --color-light-on-surface-muted: #64748B; + --color-light-primary: #0284C7; } body { @@ -27,3 +38,39 @@ body { color: var(--color-on-surface); font-family: 'Inter', 'Roboto', sans-serif; } + +/* Dark theme (default) */ +html.dark body { + background-color: var(--color-surface-darkest); + color: var(--color-on-surface); +} + +/* Light theme */ +html.light body { + background-color: var(--color-light-surface-darkest); + color: var(--color-light-on-surface); +} + +html.light { + --color-surface-darkest: var(--color-light-surface-darkest); + --color-surface-dark: var(--color-light-surface-dark); + --color-surface-medium: var(--color-light-surface-medium); + --color-surface-light: var(--color-light-surface-light); + --color-surface-lighter: var(--color-light-surface-lighter); + --color-on-surface: var(--color-light-on-surface); + --color-on-surface-variant: var(--color-light-on-surface-variant); + --color-on-surface-muted: var(--color-light-on-surface-muted); + --color-primary: var(--color-light-primary); +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton { + background: linear-gradient(90deg, var(--color-surface-light) 25%, var(--color-surface-lighter) 50%, var(--color-surface-light) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 0.5rem; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4ff0ed9..d651157 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' import ErrorBoundary from './components/ErrorBoundary' +import { ThemeProvider } from './hooks/useTheme' import './index.css' import App from './App' @@ -19,11 +20,13 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + , ) diff --git a/frontend/src/pages/HubPage.tsx b/frontend/src/pages/HubPage.tsx index 507e532..4815c1a 100644 --- a/frontend/src/pages/HubPage.tsx +++ b/frontend/src/pages/HubPage.tsx @@ -1,91 +1,198 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { listAgents } from '../services/api' -import { Activity, AlertTriangle } from 'lucide-react' +import { Activity, AlertTriangle, RefreshCw, Bot, Zap, Coffee, AlertCircle } from 'lucide-react' +import type { Agent } from '../types' + +function statusStats(agents: Agent[]) { + const counts = { total: agents.length, active: 0, idle: 0, thinking: 0, error: 0 } + for (const a of agents) { + if (a.status in counts) counts[a.status as keyof typeof counts]++ + } + return counts +} + +const STATUS_COLORS: Record = { + active: 'bg-green-500', + idle: 'bg-yellow-500', + thinking: 'bg-blue-500', + error: 'bg-red-500', +} export default function HubPage() { - const { data, isLoading, error } = useQuery({ + const queryClient = useQueryClient() + const { data, isLoading, error, refetch, isRefetching } = useQuery({ queryKey: ['agents'], queryFn: listAgents, }) + const agents = data?.data ?? [] + const stats = statusStats(agents) + if (isLoading) { - return ( -
-
-
- ) + return } if (error) { return ( -
- - Failed to load agents +
+ +

Failed to load agents

+
) } - const agents = data?.data ?? [] - return (
-
-

Command Hub

-

Agent fleet overview

+ {/* Header */} +
+
+

Command Hub

+

Agent fleet overview

+
+
+ {/* Summary stats */} +
+ + + + +
+ + {/* Agent grid */} + {agents.length === 0 ? ( +
+ +

No agents registered

+

Agents will appear here once connected.

+
+ ) : ( +
+ {agents.map((agent) => ( +
+ {/* Agent identity */} +
+
+
+ {agent.displayName.charAt(0)} +
+
+

{agent.displayName}

+

{agent.role}

+
+
+ +
+ + {/* Current task */} + {agent.currentTask && ( +
+ {agent.currentTask} +
+ )} + + {/* Progress bar */} + {agent.taskProgress !== undefined && agent.taskProgress > 0 && ( +
+
+
+ )} + + {/* Footer info */} +
+ + {agent.channel} + · + {agent.lastActivity} +
+
+ ))} +
+ )} +
+ ) +} + +function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: number; color: string }) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ) +} + +function StatusBadge({ status }: { status: string }) { + return ( +
+
+ {status} +
+ ) +} + +function HubSkeleton() { + return ( +
+
+
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
- {agents.map((agent) => ( -
-
-
-

{agent.displayName}

-

{agent.role}

+ {[...Array(6)].map((_, i) => ( +
+
+
+
+
+
- -
- - {agent.currentTask && ( -
- {agent.currentTask} -
- )} - - {agent.taskProgress !== undefined && ( -
-
-
- )} - -
- - {agent.channel} · {agent.lastActivity} +
+
+
+
))}
) } - -function StatusDot({ status }: { status: string }) { - const colorMap: Record = { - active: 'bg-status-active', - idle: 'bg-status-idle', - thinking: 'bg-status-thinking', - error: 'bg-status-error', - } - - return ( -
-
- {status} -
- ) -} diff --git a/frontend/src/pages/LogsPage.tsx b/frontend/src/pages/LogsPage.tsx index c6b93b7..cfad955 100644 --- a/frontend/src/pages/LogsPage.tsx +++ b/frontend/src/pages/LogsPage.tsx @@ -1,8 +1,182 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { listTasks } from '../services/api' +import { AlertCircle, RefreshCw, Filter, CheckCircle, Circle, Clock, XCircle, Loader, ListTodo } from 'lucide-react' +import type { Task } from '../types' + +const STATUS_FILTERS = ['all', 'pending', 'running', 'completed', 'failed'] as const +type StatusFilter = (typeof STATUS_FILTERS)[number] + +const STATUS_ICON: Record = { + pending: Clock, + running: Loader, + completed: CheckCircle, + failed: XCircle, +} + +const STATUS_COLOR: Record = { + pending: 'text-yellow-500', + running: 'text-blue-400', + completed: 'text-green-500', + failed: 'text-red-500', +} + export default function LogsPage() { + const queryClient = useQueryClient() + const [statusFilter, setStatusFilter] = useState('all') + + const { data, isLoading, error } = useQuery({ + queryKey: ['tasks'], + queryFn: listTasks, + }) + + const tasks = (data?.data ?? []) as Task[] + + const filtered = statusFilter === 'all' + ? tasks + : tasks.filter((t) => t.status === statusFilter) + + // Sort newest first + const sorted = [...filtered].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + + if (isLoading) return + + if (error) { + return ( +
+ +

Failed to load activity logs

+ +
+ ) + } + return ( -
-

Logs

-

Activity logs will appear here.

+
+
+

Activity Logs

+

Task activity across all agents

+
+ + {/* Filter tabs */} +
+ + {STATUS_FILTERS.map((f) => ( + + ))} +
+ + {/* Activity feed */} + {sorted.length === 0 ? ( +
+ +

No tasks found

+

+ {statusFilter === 'all' ? 'Tasks will appear here as agents execute work.' : `No ${statusFilter} tasks.`} +

+
+ ) : ( +
+ {sorted.map((task) => { + const Icon = STATUS_ICON[task.status] ?? Circle + const fmtTime = formatTime(task.createdAt) + return ( +
+
+ +
+
+

{task.title}

+

+ Agent {task.agentId} + {task.sessionKey && ` · ${task.sessionKey}`} +

+
+
+ + {task.status} + + {task.progress != null && task.progress > 0 && task.progress < 100 && ( + {task.progress}% + )} +
+ + {fmtTime} + +
+ ) + })} +
+ )} +
+ ) +} + +function formatTime(iso: string): string { + try { + const d = new Date(iso) + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMin = Math.floor(diffMs / 60_000) + if (diffMin < 1) return 'just now' + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + return d.toLocaleDateString() + } catch { + return iso + } +} + +function LogsSkeleton() { + return ( +
+
+
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
) } diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index a1cf9d3..2e77f02 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -1,8 +1,117 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { listProjects } from '../services/api' +import { AlertCircle, RefreshCw, FolderKanban, Users } from 'lucide-react' + +const STATUS_COLORS: Record = { + planned: 'bg-purple-500', + active: 'bg-green-500', + paused: 'bg-yellow-500', + completed: 'bg-blue-400', +} + export default function ProjectsPage() { + const queryClient = useQueryClient() + + const { data, isLoading, error } = useQuery({ + queryKey: ['projects'], + queryFn: listProjects, + }) + + const projects = data?.data ?? [] + + if (isLoading) return + + if (error) { + return ( +
+ +

Failed to load projects

+ +
+ ) + } + return ( -
-

Projects

-

Tracked projects will appear here.

+
+
+

Projects

+

Tracked projects and initiatives

+
+ + {projects.length === 0 ? ( +
+ +

No projects tracked

+

Projects synced from Linear will appear here.

+
+ ) : ( +
+ {projects.map((project) => ( +
+
+ + +
+ +

{project.name}

+ {project.description && ( +

+ {project.description} +

+ )} + +
+ + {project.agentIds?.length ?? 0} agent{(project.agentIds?.length ?? 0) !== 1 ? 's' : ''} assigned +
+
+ ))} +
+ )} +
+ ) +} + +function ProjectStatusBadge({ status }: { status: string }) { + return ( +
+
+ {status} +
+ ) +} + +function ProjectsSkeleton() { + return ( +
+
+
+
+
+
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
) } diff --git a/frontend/src/pages/SessionsPage.tsx b/frontend/src/pages/SessionsPage.tsx index 31ebc59..7d4c2b1 100644 --- a/frontend/src/pages/SessionsPage.tsx +++ b/frontend/src/pages/SessionsPage.tsx @@ -1,8 +1,177 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { listSessions } from '../services/api' +import { AlertCircle, Monitor, RefreshCw, Cpu, MessageSquare, Clock, Hash } from 'lucide-react' + export default function SessionsPage() { + const queryClient = useQueryClient() + + const { data, isLoading, error } = useQuery({ + queryKey: ['sessions'], + queryFn: listSessions, + }) + + const sessions = data?.data ?? [] + + if (isLoading) return + + if (error) { + return ( +
+ +

Failed to load sessions

+ +
+ ) + } + return ( -
-

Sessions

-

Active sessions will appear here.

+
+
+

Sessions

+

Active and recent agent sessions

+
+ + {sessions.length === 0 ? ( +
+ +

No active sessions

+

Sessions will appear when agents connect.

+
+ ) : ( + <> + {/* Desktop: Table view */} +
+ + + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + + + ))} + +
AgentSession KeyChannelModelContext TokensStarted
{s.agentId} + + {s.sessionKey} + + {s.channel}{s.model} + {s.contextTokens.toLocaleString()} + + {formatDateTime(s.startedAt)} +
+
+ + {/* Mobile: Card view */} +
+ {sessions.map((s) => ( +
+
+ + {s.agentId} +
+ +
+ {s.sessionKey} + {s.channel} + {s.model} + {s.contextTokens.toLocaleString()} + {formatDateTime(s.startedAt)} +
+
+ ))} +
+ + )} +
+ ) +} + +function Row({ icon: Icon, label, children }: { icon: React.ElementType; label: string; children: React.ReactNode }) { + return ( +
+ + {label} + {children} +
+ ) +} + +function formatDateTime(iso: string): string { + try { + const d = new Date(iso) + return d.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + } catch { + return iso + } +} + +function SessionsSkeleton() { + return ( +
+
+
+
+
+ {/* Desktop skeleton */} +
+
+
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+
+ {[...Array(5)].map((_, i) => ( +
+
+ {[...Array(6)].map((_, j) => ( +
+ ))} +
+
+ ))} +
+ {/* Mobile skeleton */} +
+ {[...Array(4)].map((_, i) => ( +
+
+ {[...Array(5)].map((_, j) => ( +
+ ))} +
+ ))} +
) } diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index f234fda..f784cad 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,8 +1,126 @@ +import { useTheme } from '../hooks/useTheme' +import { useLocalStorage } from '../hooks/useLocalStorage' +import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react' + +const REFRESH_PRESETS = [ + { label: '5s', value: 5_000 }, + { label: '10s', value: 10_000 }, + { label: '30s', value: 30_000 }, + { label: '60s', value: 60_000 }, +] + export default function SettingsPage() { + const { isDark, toggleTheme } = useTheme() + const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '') + const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000) + return ( -
-

Settings

-

System settings will appear here.

+
+
+

Settings

+

Application preferences

+
+ + {/* Appearance */} +
+

+ + Appearance +

+ +
+
+
+

Dark Mode

+

Toggle between dark and light themes

+
+ +
+
+
+ + {/* Connection */} +
+

+ + Connection +

+ +
+
+ + setGatewayUrl(e.target.value)} + placeholder="http://localhost:8080" + className="w-full px-3 py-2 rounded-lg border border-surface-light bg-surface-darkest text-on-surface placeholder:text-on-surface-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors" + /> +

+ The backend Gateway address for API requests +

+
+
+
+ + {/* Refresh */} +
+

+ + Auto Refresh +

+ +
+

Data refresh interval for agent status and logs

+ +
+ p.value === refreshInterval)} + onChange={(e) => { + const idx = parseInt(e.target.value) + setRefreshInterval(REFRESH_PRESETS[idx].value) + }} + className="w-full accent-primary" + /> +
+ {REFRESH_PRESETS.map((p) => ( + + ))} +
+
+
+
) }