Compare commits

..

2 Commits

Author SHA1 Message Date
1a50306f7d Merge branch 'dev' into agent/dex/CUB-123-gateway-integration
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m22s
2026-05-08 21:55:38 -04:00
8b8cb8210c CUB-121: build React pages with real API integration
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m11s
Dev Build / build-test (push) Successful in 2m18s
- 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
2026-05-08 19:53:21 -04:00
9 changed files with 885 additions and 79 deletions

View File

@@ -0,0 +1,29 @@
import { useState, useEffect, useCallback } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
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]
}

View File

@@ -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<ThemeContextValue | null>(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<Theme>(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 (
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -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;
}

View File

@@ -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(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</ThemeProvider>
</ErrorBoundary>
</StrictMode>,
)

View File

@@ -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<string, string> = {
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 (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)
return <HubSkeleton />
}
if (error) {
return (
<div className="flex items-center justify-center h-64 text-danger">
<AlertTriangle size={24} className="mr-2" />
Failed to load agents
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle size={48} className="text-danger" />
<p className="text-danger text-lg">Failed to load agents</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['agents'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
)
}
const agents = data?.data ?? []
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold">Command Hub</h1>
<p className="text-on-surface-variant">Agent fleet overview</p>
{/* Header */}
<header className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-2xl font-bold">Command Hub</h1>
<p className="text-on-surface-variant">Agent fleet overview</p>
</div>
<button
onClick={() => refetch()}
disabled={isRefetching}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isRefetching ? 'animate-spin' : ''} />
Refresh
</button>
</header>
{/* Summary stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard icon={Bot} label="Total" value={stats.total} color="text-on-surface" />
<StatCard icon={Zap} label="Active" value={stats.active} color="text-green-500" />
<StatCard icon={Coffee} label="Idle" value={stats.idle} color="text-yellow-500" />
<StatCard icon={AlertTriangle} label="Errors" value={stats.error} color="text-red-500" />
</div>
{/* Agent grid */}
{agents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<Bot size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No agents registered</p>
<p className="text-on-surface-muted text-sm">Agents will appear here once connected.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{agents.map((agent) => (
<div
key={agent.id}
className="p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
>
{/* Agent identity */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-surface-light flex items-center justify-center text-lg font-bold shrink-0">
{agent.displayName.charAt(0)}
</div>
<div>
<h3 className="font-semibold text-sm">{agent.displayName}</h3>
<p className="text-xs text-on-surface-variant">{agent.role}</p>
</div>
</div>
<StatusBadge status={agent.status} />
</div>
{/* Current task */}
{agent.currentTask && (
<div className="mb-2 text-sm text-on-surface-variant truncate">
{agent.currentTask}
</div>
)}
{/* Progress bar */}
{agent.taskProgress !== undefined && agent.taskProgress > 0 && (
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden mb-2">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${Math.min(agent.taskProgress, 100)}%` }}
/>
</div>
)}
{/* Footer info */}
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
<Activity size={12} />
<span>{agent.channel}</span>
<span>·</span>
<span>{agent.lastActivity}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: number; color: string }) {
return (
<div className="p-4 rounded-xl border border-surface-light bg-surface-dark flex items-center gap-3">
<div className={`p-2 rounded-lg bg-surface-light ${color}`}>
<Icon size={20} />
</div>
<div>
<p className="text-xs text-on-surface-variant">{label}</p>
<p className="text-xl font-bold">{value}</p>
</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
return (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
</div>
)
}
function HubSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-48 skeleton mb-2" />
<div className="h-4 w-36 skeleton" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg skeleton" />
<div className="flex-1">
<div className="h-3 w-12 skeleton mb-2" />
<div className="h-6 w-8 skeleton" />
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{agents.map((agent) => (
<div
key={agent.id}
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold">{agent.displayName}</h3>
<p className="text-xs text-on-surface-variant">{agent.role}</p>
{[...Array(6)].map((_, i) => (
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
<div className="flex items-start gap-3 mb-3">
<div className="h-10 w-10 rounded-full skeleton shrink-0" />
<div className="flex-1">
<div className="h-4 w-24 skeleton mb-1" />
<div className="h-3 w-16 skeleton" />
</div>
<StatusDot status={agent.status} />
</div>
{agent.currentTask && (
<div className="text-sm text-on-surface-variant mb-2">
{agent.currentTask}
</div>
)}
{agent.taskProgress !== undefined && (
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${agent.taskProgress}%` }}
/>
</div>
)}
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
<Activity size={12} />
{agent.channel} · {agent.lastActivity}
<div className="h-6 w-16 rounded-full skeleton" />
</div>
<div className="h-4 w-full skeleton mb-2" />
<div className="h-2 w-full skeleton rounded-full" />
<div className="mt-3 h-3 w-32 skeleton" />
</div>
))}
</div>
</div>
)
}
function StatusDot({ status }: { status: string }) {
const colorMap: Record<string, string> = {
active: 'bg-status-active',
idle: 'bg-status-idle',
thinking: 'bg-status-thinking',
error: 'bg-status-error',
}
return (
<div className="flex items-center gap-1">
<div className={`w-2.5 h-2.5 rounded-full ${colorMap[status] ?? 'bg-status-offline'}`} />
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
</div>
)
}

View File

@@ -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<string, React.ElementType> = {
pending: Clock,
running: Loader,
completed: CheckCircle,
failed: XCircle,
}
const STATUS_COLOR: Record<string, string> = {
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<StatusFilter>('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 <LogsSkeleton />
if (error) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle size={48} className="text-danger" />
<p className="text-danger text-lg">Failed to load activity logs</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['tasks'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
)
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Logs</h1>
<p className="text-on-surface-variant">Activity logs will appear here.</p>
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold">Activity Logs</h1>
<p className="text-on-surface-variant">Task activity across all agents</p>
</header>
{/* Filter tabs */}
<div className="flex items-center gap-1 flex-wrap">
<Filter size={16} className="text-on-surface-muted mr-1" />
{STATUS_FILTERS.map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`px-3 py-1.5 rounded-lg text-sm capitalize transition-colors ${
statusFilter === f
? 'bg-primary/10 text-primary'
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
}`}
>
{f}
</button>
))}
</div>
{/* Activity feed */}
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<ListTodo size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No tasks found</p>
<p className="text-on-surface-muted text-sm">
{statusFilter === 'all' ? 'Tasks will appear here as agents execute work.' : `No ${statusFilter} tasks.`}
</p>
</div>
) : (
<div className="space-y-2">
{sorted.map((task) => {
const Icon = STATUS_ICON[task.status] ?? Circle
const fmtTime = formatTime(task.createdAt)
return (
<div
key={task.id}
className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
>
<div className={`mt-0.5 shrink-0 ${STATUS_COLOR[task.status] ?? 'text-on-surface-muted'}`}>
<Icon size={20} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{task.title}</p>
<p className="text-xs text-on-surface-variant mt-0.5">
Agent {task.agentId}
{task.sessionKey && ` · ${task.sessionKey}`}
</p>
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className={`text-xs capitalize px-2 py-0.5 rounded-full font-medium ${
statusFilter !== 'all'
? 'bg-primary/10 text-primary'
: 'bg-surface-light text-on-surface-variant'
}`}>
{task.status}
</span>
{task.progress != null && task.progress > 0 && task.progress < 100 && (
<span className="text-xs text-on-surface-muted">{task.progress}%</span>
)}
</div>
<span className="shrink-0 text-xs text-on-surface-muted whitespace-nowrap">
{fmtTime}
</span>
</div>
)
})}
</div>
)}
</div>
)
}
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 (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-44 skeleton mb-2" />
<div className="h-4 w-56 skeleton" />
</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 w-20 rounded-lg skeleton" />
))}
</div>
<div className="space-y-2">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark">
<div className="h-5 w-5 rounded-full skeleton shrink-0" />
<div className="flex-1">
<div className="h-4 w-3/4 skeleton mb-1" />
<div className="h-3 w-1/2 skeleton" />
</div>
<div className="h-6 w-20 rounded-full skeleton" />
<div className="h-3 w-16 skeleton" />
</div>
))}
</div>
</div>
)
}

View File

@@ -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<string, string> = {
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 <ProjectsSkeleton />
if (error) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle size={48} className="text-danger" />
<p className="text-danger text-lg">Failed to load projects</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['projects'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
)
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Projects</h1>
<p className="text-on-surface-variant">Tracked projects will appear here.</p>
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold">Projects</h1>
<p className="text-on-surface-variant">Tracked projects and initiatives</p>
</header>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<FolderKanban size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No projects tracked</p>
<p className="text-on-surface-muted text-sm">Projects synced from Linear will appear here.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<div
key={project.id}
className="p-5 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors flex flex-col"
>
<div className="flex items-start justify-between mb-3">
<FolderKanban size={20} className="text-on-surface-variant shrink-0 mt-0.5" />
<ProjectStatusBadge status={project.status} />
</div>
<h3 className="font-semibold mb-1">{project.name}</h3>
{project.description && (
<p className="text-sm text-on-surface-variant mb-4 line-clamp-2 flex-1">
{project.description}
</p>
)}
<div className="flex items-center gap-2 text-xs text-on-surface-muted pt-3 border-t border-surface-light">
<Users size={14} />
<span>{project.agentIds?.length ?? 0} agent{(project.agentIds?.length ?? 0) !== 1 ? 's' : ''} assigned</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
function ProjectStatusBadge({ status }: { status: string }) {
return (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
</div>
)
}
function ProjectsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-40 skeleton mb-2" />
<div className="h-4 w-56 skeleton" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="p-5 rounded-xl border border-surface-light bg-surface-dark">
<div className="flex justify-between mb-3">
<div className="h-5 w-5 rounded skeleton" />
<div className="h-6 w-20 rounded-full skeleton" />
</div>
<div className="h-5 w-3/4 skeleton mb-2" />
<div className="h-4 w-full skeleton mb-2" />
<div className="h-4 w-2/3 skeleton mb-4" />
<div className="pt-3 border-t border-surface-light">
<div className="h-3 w-32 skeleton" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -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 <SessionsSkeleton />
if (error) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle size={48} className="text-danger" />
<p className="text-danger text-lg">Failed to load sessions</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['sessions'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
)
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Sessions</h1>
<p className="text-on-surface-variant">Active sessions will appear here.</p>
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold">Sessions</h1>
<p className="text-on-surface-variant">Active and recent agent sessions</p>
</header>
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<Monitor size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No active sessions</p>
<p className="text-on-surface-muted text-sm">Sessions will appear when agents connect.</p>
</div>
) : (
<>
{/* Desktop: Table view */}
<div className="hidden lg:block overflow-x-auto rounded-xl border border-surface-light">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-light bg-surface-dark">
<th className="text-left p-4 font-medium text-on-surface-variant">Agent</th>
<th className="text-left p-4 font-medium text-on-surface-variant">Session Key</th>
<th className="text-left p-4 font-medium text-on-surface-variant">Channel</th>
<th className="text-left p-4 font-medium text-on-surface-variant">Model</th>
<th className="text-right p-4 font-medium text-on-surface-variant">Context Tokens</th>
<th className="text-right p-4 font-medium text-on-surface-variant">Started</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr
key={s.id}
className="border-b border-surface-light hover:bg-surface-dark/50 transition-colors"
>
<td className="p-4 font-medium">{s.agentId}</td>
<td className="p-4">
<code className="text-xs bg-surface-light px-2 py-1 rounded text-on-surface-variant">
{s.sessionKey}
</code>
</td>
<td className="p-4 text-on-surface-variant">{s.channel}</td>
<td className="p-4 text-on-surface-variant">{s.model}</td>
<td className="p-4 text-right tabular-nums text-on-surface-variant">
{s.contextTokens.toLocaleString()}
</td>
<td className="p-4 text-right text-on-surface-muted whitespace-nowrap">
{formatDateTime(s.startedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: Card view */}
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
{sessions.map((s) => (
<div
key={s.id}
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
>
<div className="flex items-center gap-2 mb-3">
<Monitor size={16} className="text-primary" />
<span className="font-medium text-sm">{s.agentId}</span>
</div>
<div className="space-y-2 text-sm">
<Row icon={Hash} label="Session">{s.sessionKey}</Row>
<Row icon={MessageSquare} label="Channel">{s.channel}</Row>
<Row icon={Cpu} label="Model">{s.model}</Row>
<Row icon={Hash} label="Tokens">{s.contextTokens.toLocaleString()}</Row>
<Row icon={Clock} label="Started">{formatDateTime(s.startedAt)}</Row>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
function Row({ icon: Icon, label, children }: { icon: React.ElementType; label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<Icon size={14} className="text-on-surface-muted shrink-0" />
<span className="text-on-surface-muted text-xs w-14 shrink-0">{label}</span>
<span className="text-on-surface-variant truncate">{children}</span>
</div>
)
}
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 (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-40 skeleton mb-2" />
<div className="h-4 w-56 skeleton" />
</div>
{/* Desktop skeleton */}
<div className="hidden lg:block rounded-xl border border-surface-light overflow-hidden">
<div className="bg-surface-dark p-4 border-b border-surface-light">
<div className="grid grid-cols-6 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-3 skeleton" />
))}
</div>
</div>
{[...Array(5)].map((_, i) => (
<div key={i} className="border-b border-surface-light p-4">
<div className="grid grid-cols-6 gap-4">
{[...Array(6)].map((_, j) => (
<div key={j} className="h-4 skeleton" />
))}
</div>
</div>
))}
</div>
{/* Mobile skeleton */}
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
<div className="h-4 w-24 skeleton mb-3" />
{[...Array(5)].map((_, j) => (
<div key={j} className="h-4 w-full skeleton mb-2" />
))}
</div>
))}
</div>
</div>
)
}

View File

@@ -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 (
<div>
<h1 className="text-2xl font-bold mb-4">Settings</h1>
<p className="text-on-surface-variant">System settings will appear here.</p>
<div className="space-y-8 max-w-2xl">
<header>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-on-surface-variant">Application preferences</p>
</header>
{/* Appearance */}
<section className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Monitor size={20} className="text-primary" />
Appearance
</h2>
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Dark Mode</p>
<p className="text-sm text-on-surface-variant">Toggle between dark and light themes</p>
</div>
<button
onClick={toggleTheme}
className={`relative w-14 h-8 rounded-full transition-colors duration-200 ${
isDark ? 'bg-primary' : 'bg-surface-lighter'
}`}
aria-label="Toggle dark mode"
>
<div
className={`absolute top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200 flex items-center justify-center ${
isDark ? 'translate-x-7' : 'translate-x-1'
}`}
>
{isDark ? <Moon size={14} className="text-surface-darkest" /> : <Sun size={14} className="text-yellow-500" />}
</div>
</button>
</div>
</div>
</section>
{/* Connection */}
<section className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Zap size={20} className="text-primary" />
Connection
</h2>
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
<div>
<label htmlFor="gateway-url" className="block text-sm font-medium mb-1">
Gateway URL
</label>
<input
id="gateway-url"
type="url"
value={gatewayUrl}
onChange={(e) => 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"
/>
<p className="text-xs text-on-surface-muted mt-1">
The backend Gateway address for API requests
</p>
</div>
</div>
</section>
{/* Refresh */}
<section className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Clock size={20} className="text-primary" />
Auto Refresh
</h2>
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
<div className="flex flex-col gap-2">
<input
type="range"
min="0"
max="3"
step="1"
value={REFRESH_PRESETS.findIndex((p) => p.value === refreshInterval)}
onChange={(e) => {
const idx = parseInt(e.target.value)
setRefreshInterval(REFRESH_PRESETS[idx].value)
}}
className="w-full accent-primary"
/>
<div className="flex justify-between text-xs text-on-surface-muted">
{REFRESH_PRESETS.map((p) => (
<button
key={p.label}
onClick={() => setRefreshInterval(p.value)}
className={`px-3 py-1 rounded-lg transition-colors ${
refreshInterval === p.value
? 'bg-primary/10 text-primary'
: 'hover:bg-surface-light'
}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
</section>
</div>
)
}