Client-side rendering for the per-lane runtime facts (slot, ports, database, Redis index, service liveness) added in the server-side A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md, client/server READMEs) describing the new profile.env keys, hook environment contract, and REST endpoints.
35 KiB
Client Application
Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent monitoring.
Table of Contents
- Overview
- Architecture
- Component Hierarchy
- State Management
- WebSocket Integration
- Routing
- API Client
- UI Components
- Utilities
- Testing
- Build & Deployment
- Development
- Performance
- Accessibility
Overview
The client is a single-page application (SPA) built with modern web technologies:
- React 18.3 - Component-based UI with hooks and concurrent features
- TypeScript 5.7 - Full type safety across components, utilities, and API contracts
- Vite 6.1 - Lightning-fast HMR during development, optimized production builds
- Tailwind CSS 3.4 - Utility-first CSS framework; colors are CSS-variable-backed tokens (
darkMode: "class") so a Dark/Light toggle (next to the language switcher in the sidebar,useThemehook) re-themes the whole app by flipping one class - React Router 6.28 - Client-side routing with nested layouts
- WebSocket - Real-time event streaming from server
- Lucide Icons - Modern, consistent icon set
graph TB
subgraph "Browser Runtime"
subgraph "React Application"
Router[React Router]
Layout[Layout Component]
subgraph "Pages"
Home[Dashboard]
Kanban[KanbanBoard]
Sessions[Sessions]
Detail[SessionDetail]
Feed[ActivityFeed]
Analytics[Analytics]
Workflows[Workflows]
Settings[Settings]
end
subgraph "Shared Components"
AgentCard[AgentCard]
StatCard[StatCard]
StatusBadge[StatusBadge]
EventDetail[EventDetail]
EmptyState[EmptyState]
end
end
subgraph "Core Services"
API[API Client]
WS[WebSocket Manager]
Bus[Event Bus]
Notif[Notification Manager]
end
end
subgraph "Server (localhost:4820)"
REST[REST API]
WSS[WebSocket Server]
end
Router --> Layout
Layout --> Home & Kanban & Sessions & Detail & Feed & Analytics & Workflows & Settings
Home & Detail --> AgentCard & StatCard & StatusBadge
Feed --> EventDetail
API --> REST
WS --> WSS
Bus --> Notif
WS --> Bus
style Router fill:#61DAFB
style API fill:#10B981
style WS fill:#F59E0B
style Bus fill:#8B5CF6
Architecture
Component Architecture
The client follows a layered architecture with clear separation of concerns:
graph TB
subgraph "Presentation Layer"
Pages[Pages/Routes]
Components[Reusable Components]
Hooks[Custom Hooks]
end
subgraph "Business Logic Layer"
EventBus[Event Bus<br/>Pub/Sub]
NotifMgr[Notification Manager]
WSManager[WebSocket Manager]
end
subgraph "Data Access Layer"
APIClient[API Client<br/>Fetch Wrapper]
WSClient[WebSocket Client]
end
subgraph "Utility Layer"
Format[Formatters<br/>fmt, fmtCost, timeAgo]
Types[TypeScript Types]
Const[Constants]
end
Pages --> Components
Pages --> Hooks
Components --> Hooks
Hooks --> EventBus
Hooks --> NotifMgr
Hooks --> WSManager
Hooks --> APIClient
WSManager --> WSClient
WSManager --> EventBus
APIClient --> Format
Components --> Format
style Pages fill:#3B82F6
style EventBus fill:#8B5CF6
style APIClient fill:#10B981
style Format fill:#F59E0B
Directory Structure
client/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── __tests__/ # Component tests
│ │ ├── AgentCard.tsx
│ │ ├── StatCard.tsx
│ │ ├── StatusBadge.tsx
│ │ ├── EventDetail.tsx # Inline hook payload viewer (used by ActivityFeed + SessionDetail)
│ │ ├── EmptyState.tsx
│ │ ├── Sidebar.tsx
│ │ ├── Layout.tsx
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
│ │ └── workflows/ # D3.js workflow visualization components (12 files)
│ │
│ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx
│ │ ├── KanbanBoard.tsx
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Analytics.tsx
│ │ ├── Workflows.tsx
│ │ ├── Settings.tsx
│ │ └── NotFound.tsx
│ │
│ ├── lib/ # Core utilities & business logic
│ │ ├── __tests__/ # Utility tests
│ │ ├── api.ts # REST API client
│ │ ├── eventBus.ts # WebSocket pub/sub + connection state
│ │ ├── dataScope.ts # Global data-scope store (app-wide ?sources= selection)
│ │ ├── format.ts # Formatters (formatTime, timeAgo, fmtCost)
│ │ └── types.ts # TypeScript type definitions
│ │
│ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
│ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── App.tsx # Root component + router setup
│ ├── main.tsx # Entry point
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
│
├── public/ # Static assets (sw.js service worker)
├── index.html # HTML template
├── vite.config.ts # Vite + proxy config
├── tailwind.config.js # Dark/light color tokens (`darkMode: "class"`, CSS-variable-backed)
├── tsconfig.json # Strict TypeScript config
└── package.json
Component Hierarchy
Page Components
graph TB
App[App.tsx<br/>Router + WS + Notifications]
Layout[Layout.tsx<br/>Sidebar + Outlet]
Dashboard[Dashboard<br/>Monitor tab: stats + agents + events<br/>Health tab: SystemHealthTab]
Kanban[KanbanBoard<br/>4-column agent board]
Sessions[Sessions<br/>filterable table]
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Analytics[Analytics<br/>tokens + heatmap + trends]
Workflows[Workflows<br/>D3.js visualizations]
Settings[Settings<br/>pricing + notifications + hooks]
App --> Layout
Layout --> Dashboard & Kanban & Sessions & Detail & Feed & Analytics & Workflows & Settings
Dashboard --> StatCard[StatCard × 6]
Dashboard --> AgentCard[AgentCard × N]
Dashboard --> HealthTab["SystemHealthTab<br/>(health score, storage donut,<br/>gauges, tool bars, subagent<br/>effectiveness, model tokens)"]
Detail --> AgentCard
Feed --> EventDetail[EventDetail<br/>inline payload viewer]
Detail --> EventDetail
style App fill:#1E40AF
style Layout fill:#3B82F6
style Feed fill:#8B5CF6
style EventDetail fill:#10B981
Component Props Flow
sequenceDiagram
participant Router
participant Page
participant Component
participant API
participant WS
Router->>Page: Navigate (params from URL)
Page->>API: Fetch initial data
API-->>Page: Return data
Page->>Component: Pass data as props
Component->>Component: Render UI
Note over WS: Real-time updates
WS->>Page: Event via EventBus
Page->>Page: Update local state
Page->>Component: Re-render with new props
State Management
The client uses local component state and React hooks for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the data-scope store (lib/dataScope.ts): a lightweight app-wide store holding the current set of data sources (local plus any configured Remote Data Sources). Pages read it and append ?sources= to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the RemoteSources component (components/RemoteSources.tsx), which drives the /api/remote-sources CRUD/test/sync endpoints and reflects live remote_source.status WebSocket updates. When a sync finishes, stats pages refetch via lib/remoteDataEvents.ts (remote_data.updated, remote_source.status with ok, or remote import.progress complete).
Cursor sessions (informational): Settings surfaces a subtle note on the CLAUDE_HOME, Import History, and Remote Data Sources panels — Cursor agent sessions count too because Cursor stores transcripts under the same ~/.claude paths as Claude Code locally (and on synced remotes).
State Strategy
graph TB
subgraph "State Sources"
URL[URL Params<br/>React Router]
Local[Component State<br/>useState]
API[Server API<br/>REST fetch]
WS[WebSocket Events<br/>Real-time]
end
subgraph "State Consumers"
Pages[Page Components]
Components[Child Components]
end
URL --> Pages
API --> Pages
WS --> Pages
Pages --> Local
Local --> Components
style URL fill:#F59E0B
style API fill:#10B981
style WS fill:#EF4444
style Local fill:#3B82F6
State Update Pattern
- Initial Load: Page component fetches data via API client on mount (
useEffect) - URL Changes: React Router triggers re-render, page refetches data
- Real-time Updates: WebSocket events trigger state updates via
EventBus - User Actions: Click handlers call API, optimistically update local state
Example from SessionDetailPage:
function SessionDetailPage() {
const { sessionId } = useParams();
const [session, setSession] = useState(null);
const [agents, setAgents] = useState([]);
// Initial load
useEffect(() => {
fetchSession(sessionId).then(setSession);
fetchAgents(sessionId).then(setAgents);
}, [sessionId]);
// Real-time updates
useEffect(() => {
const unsubscribe = eventBus.on('agent.created', (agent) => {
if (agent.session_id === sessionId) {
setAgents(prev => [...prev, agent]);
}
});
return unsubscribe;
}, [sessionId]);
}
WebSocket Integration
WebSocket Lifecycle
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connecting: connect()
Connecting --> Connected: onopen
Connecting --> Disconnected: onerror
Connected --> Disconnected: onclose
Connected --> Connected: onmessage
Disconnected --> Connecting: auto-reconnect (3s)
note right of Connected
Heartbeat every 30s
Emit events to EventBus
end note
WebSocket Message Flow
sequenceDiagram
participant Server
participant WS as WebSocket<br/>Manager
participant Bus as Event Bus
participant Page as Page Component
participant UI
Server->>WS: { type: 'session.created', data: {...} }
WS->>WS: Parse JSON
WS->>Bus: emit('session.created', data)
Bus->>Page: callback(data)
Page->>Page: Update state
Page->>UI: Re-render
Event Types
Server broadcasts these event types over WebSocket:
| Event Type | Payload | Triggered By |
|---|---|---|
session.created |
Session object | SessionStart hook |
session.updated |
Session object | Any hook touching session |
agent.created |
Agent object | PreToolUse hook |
agent.updated |
Agent object | PostToolUse/Stop hooks |
tool.executed |
Tool execution record | PostToolUse hook |
notification.received |
Notification object | Notification hook |
remote_source.status |
{ id, status, error?, last_sync_at? } (status: idle/syncing/ok/error/deleted) |
Remote Data Source sync poller + /api/remote-sources routes |
remote_data.updated |
{ sourceId, source, label?, counters?, last_sync_at? } |
Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts session_created / session_updated (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
lane_hook_output |
{ laneId, hook, stream, line } |
One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; LaneCard shows the latest line so a boot does not read as a hang |
lane_runtime |
{ laneId, runtime? , error? } |
A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
lane_hook_result |
{ laneId, hook, code, error? } |
A profile hook exited (POST /api/lanes/:id/hook/:name). code is null when the hook could not be started |
EventBus Pattern
The eventBus is a simple pub/sub system:
// lib/eventBus.ts
class EventBus {
private listeners = new Map<string, Set<Function>>();
on(event: string, callback: Function): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
// Return unsubscribe function
return () => this.listeners.get(event)?.delete(callback);
}
emit(event: string, data: any): void {
this.listeners.get(event)?.forEach(cb => cb(data));
}
}
export const eventBus = new EventBus();
Usage in components:
useEffect(() => {
const unsubscribe = eventBus.on('session.created', handleNewSession);
return unsubscribe; // Cleanup on unmount
}, []);
Routing
Route Structure
graph TB
Root["/"]
Dashboard["/ (Dashboard)"]
Kanban["/kanban"]
Sessions["/sessions"]
Detail["/sessions/:id"]
Feed["/activity"]
Analytics["/analytics"]
Workflows["/workflows"]
CcConfig["/cc-config"]
Run["/run"]
Settings["/settings"]
NF["/* (NotFound)"]
Root --> Dashboard & Kanban & Sessions & Detail & Feed & Analytics & Workflows & CcConfig & Run & Settings & NF
style Dashboard fill:#3B82F6
style Detail fill:#3B82F6
style Feed fill:#8B5CF6
Route Configuration
// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="kanban" element={<KanbanBoard />} />
<Route path="sessions" element={<Sessions />} />
<Route path="sessions/:id" element={<SessionDetail />} />
<Route path="activity" element={<ActivityFeed />} />
<Route path="analytics" element={<Analytics />} />
<Route path="workflows" element={<Workflows />} />
<Route path="cc-config" element={<CcConfig />} />
<Route path="run" element={<Run />} />
<Route path="settings" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
Navigation Flow
sequenceDiagram
participant User
participant UI
participant Router
participant Page
User->>UI: Click session row in Sessions table
UI->>Router: navigate('/sessions/123')
Router->>Page: Mount SessionDetail
Page->>Page: Read params.id = '123'
Page->>Page: Fetch session data
Page->>UI: Render agent tree + event timeline
Note over UI: In ActivityFeed, clicking a row<br/>expands the inline payload panel.<br/>The "Session →" button on each row<br/>navigates to /sessions/:id instead.
API Client
API Architecture
graph LR
Component[React Component] --> API[api.ts]
API --> Fetch[fetch API]
Fetch --> Server[Server :4820]
Server --> Response[JSON Response]
Response --> API
API --> Component
style API fill:#10B981
style Server fill:#3B82F6
API Client Structure
// lib/api.ts
const BASE_URL = 'http://localhost:4820';
class APIClient {
private async request(path: string, options?: RequestInit) {
const response = await fetch(`${BASE_URL}${path}`, options);
if (!response.ok) throw new Error(`API error: ${response.statusText}`);
return response.json();
}
// Sessions
getSessions() { return this.request('/api/sessions'); }
getSession(id: string) { return this.request(`/api/sessions/${id}`); }
// Agents
getAgents(sessionId: string) {
return this.request(`/api/sessions/${sessionId}/agents`);
}
getAgent(id: string) { return this.request(`/api/agents/${id}`); }
// Tools
getTools(agentId: string) {
return this.request(`/api/agents/${agentId}/tools`);
}
// Pricing
getPricingRules() { return this.request('/api/pricing'); }
createPricingRule(rule: PricingRule) {
return this.request('/api/pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule)
});
}
deletePricingRule(pattern: string) {
return this.request(`/api/pricing/${encodeURIComponent(pattern)}`, {
method: 'DELETE'
});
}
}
export const api = new APIClient();
API reference: the endpoints this client calls are fully documented by the server's OpenAPI 3.0.3 spec. With the dashboard running (default port
4820), explore them at/api/docs(interactive Swagger UI),/api/redoc(read-optimized ReDoc reference), or/api/openapi.json(raw spec). A committedopenapi.yamlat the repo root mirrors the live spec.
Error Handling
graph TB
Request[API Request]
FetchCall[Call fetch API]
NetworkError{Network Error?}
HTTPError{HTTP Error?}
Success[Return JSON]
Request --> FetchCall
FetchCall --> NetworkError
NetworkError -->|Yes| Throw1[Throw Network Error]
NetworkError -->|No| HTTPError
HTTPError -->|Yes| Throw2[Throw HTTP Error]
HTTPError -->|No| Success
style Throw1 fill:#EF4444
style Throw2 fill:#EF4444
style Success fill:#10B981
UI Components
Component Catalog
SessionCard
Displays session summary with status, model, cost, and agent count.
Props:
interface SessionCardProps {
session: Session;
}
Visual Structure:
┌────────────────────────────────────────┐
│ 🟢 Session Title $0.45 │
│ claude-sonnet-4 │
│ Started: 2 hours ago │
│ Agents: 3 | Tools: 12 │
└────────────────────────────────────────┘
AgentCard
Shows agent type, status, tool usage, and cost breakdown.
Props:
interface AgentCardProps {
agent: Agent;
}
StatusBadge
Colored status pills for agents (AgentStatusBadge) and sessions (SessionStatusBadge). When a row is in the yellow Waiting overlay (awaiting_input_since set), an optional reason prop explains WHY: a hover tooltip carries the full explanation, and — unless compact is set — a small nested chip (icon + short label) renders inline. Card layouts (Kanban / Dashboard trees) pass compact so the chip never squeezes the card title; the Sessions table and session-detail header show the full chip:
awaiting_reason |
Label | Meaning |
|---|---|---|
notification |
Needs input | Blocked on a permission prompt / input request (urgent — amber) |
stop |
Turn done | Claude finished its reply; idle until the next prompt |
session_start |
At prompt | Fresh/resumed CLI sitting at an empty prompt |
interrupted |
Interrupted | Turn cut short — Esc or a recovered hook (urgent — amber) |
Props:
interface AgentStatusBadgeProps {
status: EffectiveAgentStatus;
pulse?: boolean;
reason?: AwaitingReason | null; // from agentAwaitingReason(agent)
compact?: boolean; // tooltip-only (no inline chip) for tight card layouts
}
Unknown/future server reasons degrade to a plain Waiting badge (normalizeAwaitingReason filters them to null). SessionDetail additionally renders a waiting-for-input banner (same reason + relative time) under the header via the shared REASON_ICONS map.
ToolCard
Displays tool execution details with timing and token usage.
Props:
interface ToolCardProps {
tool: ToolExecution;
}
EventTimeline
Chronological view of session events (hooks, tools, notifications).
graph TB
Timeline[EventTimeline]
Events[Event List]
Event1[SessionStart]
Event2[PreToolUse]
Event3[PostToolUse]
Event4[Notification]
Timeline --> Events
Events --> Event1
Events --> Event2
Events --> Event3
Events --> Event4
style Event1 fill:#10B981
style Event2 fill:#3B82F6
style Event3 fill:#8B5CF6
style Event4 fill:#F59E0B
ActivityFeed (pages/ActivityFeed.tsx)
Real-time streaming event log with pause/resume, pagination, and inline payload expansion.
UX interaction model:
flowchart LR
ROW["Event row\n(role=button)"] -->|click| EXPAND["Toggle EventDetail\n(inline payload)"]
ROW --> SESSBTN["Session → button\n(right edge)"]
SESSBTN -->|click + stopPropagation| NAV["/sessions/:id"]
EXPAND --> ED["EventDetail.tsx\nparsed fields + JSON blocks"]
style ROW fill:#1a1a28,stroke:#4f4f6a,color:#e4e4ed
style SESSBTN fill:#3B82F6,stroke:#60A5FA,color:#fff
style ED fill:#10B981,stroke:#34D399,color:#fff
style NAV fill:#8B5CF6,stroke:#A78BFA,color:#fff
- The entire row is clickable (keyboard accessible via
Enter/Space) and toggles theEventDetaildropdown. - The chevron icon rotates 90° when a row is expanded — it is a visual indicator only, not a separate button.
- The Session → button uses
e.stopPropagation()so navigating to session details never collapses an open payload panel. - Multiple rows can be expanded simultaneously (state stored in
Set<number>).
EventDetail (components/EventDetail.tsx)
Renders the hook payload for a single event inline below its row. Scalars appear as key: value pairs; objects and arrays render in a terminal-styled code block with a copy button.
Utilities
Formatters (lib/format.ts)
graph LR
subgraph "Formatting Functions"
fmt[fmt<br/>Number formatting]
fmtCost[fmtCost<br/>Currency formatting]
timeAgo[timeAgo<br/>Relative time]
end
Components[UI Components] --> fmt
Components --> fmtCost
Components --> timeAgo
fmt --> Output1["1,234,567"]
fmtCost --> Output2["$12.34"]
timeAgo --> Output3["2 hours ago"]
style fmt fill:#10B981
style fmtCost fill:#10B981
style timeAgo fill:#10B981
Function Signatures:
// Format large numbers with commas
export function fmt(n: number | null | undefined): string;
// Examples: fmt(1234) → "1,234"
// fmt(null) → "—"
// Format cost in dollars
export function fmtCost(cost: number | null | undefined): string;
// Examples: fmtCost(1.234) → "$1.23"
// fmtCost(0) → "$0.00"
// Relative time string
export function timeAgo(date: string | Date | null | undefined): string;
// Examples: timeAgo('2024-03-18T12:00:00Z') → "2 hours ago"
// timeAgo(null) → "—"
Type Definitions (lib/types.ts)
All TypeScript interfaces match server response shapes:
interface Session {
id: string;
session_id: string;
model: string;
status: 'active' | 'completed' | 'error' | 'abandoned';
total_cost: number;
created_at: string;
updated_at: string;
}
interface Agent {
id: number;
agent_id: string;
session_id: string;
agent_type: string;
status: 'working' | 'waiting' | 'completed' | 'error';
input_tokens: number;
output_tokens: number;
cost: number;
created_at: string;
}
interface ToolExecution {
id: number;
agent_id: string;
tool_name: string;
duration_ms: number;
success: boolean;
created_at: string;
}
Testing
Test Stack
- Vitest - Fast unit test runner (Vite-native)
- React Testing Library - Component testing
- jsdom - Browser environment simulation
Test Structure
client/src/
├── components/__tests__/
│ ├── AgentCard.test.tsx
│ ├── SessionCard.test.tsx
│ └── EventTimeline.test.tsx
│
├── pages/__tests__/
│ ├── screens.snapshot.test.tsx # render snapshots for every screen
│ └── __snapshots__/ # committed .snap baselines
│
└── lib/__tests__/
├── format.test.ts
├── eventBus.test.ts
└── api.test.ts
Running Tests
# Run all tests
npm test
# Watch mode
npm run test:watch
# Coverage report
npm run test:coverage
Example Test
// components/__tests__/SessionCard.test.tsx
import { render, screen } from '@testing-library/react';
import { SessionCard } from '../SessionCard';
test('renders session title and cost', () => {
const session = {
id: '1',
session_id: 'sess_123',
model: 'claude-sonnet-4',
total_cost: 1.23,
status: 'active',
created_at: '2024-03-18T12:00:00Z'
};
render(<SessionCard session={session} />);
expect(screen.getByText('sess_123')).toBeInTheDocument();
expect(screen.getByText('$1.23')).toBeInTheDocument();
});
Snapshot Testing
pages/__tests__/screens.snapshot.test.tsx renders every routed screen
(Dashboard, Kanban, Sessions, Session detail, Activity feed, Analytics,
Workflows, Claude Config, Run, Settings, Not found) and asserts each against a
committed snapshot in pages/__tests__/__snapshots__/. These are structural
regression guards — they catch unintended changes to layout, markup, or
localized copy.
To keep snapshots deterministic across machines and CI, the suite:
- mocks the API layer (
vi.mock("../../lib/api", …)) to a loaded-empty state (empty collections + zeroed scalars), so no live data or noisy chart DOM leaks in —importOriginalkeeps non-apiexports real; - stubs
eventBus, push notifications, and the jsdom-missingResizeObserver/IntersectionObserver/matchMedia/scroll*APIs; - pins the clock (
vi.useFakeTimers) and timezone (TZ=UTC) so any rendered timestamps are stable.
When you change a screen intentionally, review the diff and regenerate the baselines:
cd client && npx vitest run -u src/pages/__tests__/screens.snapshot.test.tsx
Commit the updated .snap file alongside the change.
Build & Deployment
Development Build
npm run dev
Starts Vite dev server with HMR at http://localhost:5173
graph LR
Source[src/**/*.tsx] --> Vite[Vite Dev Server]
Vite --> HMR[Hot Module<br/>Replacement]
HMR --> Browser[Browser]
Browser -->|Changes| Vite
style Vite fill:#646CFF
Production Build
npm run build
Output: client/dist/ (optimized static files)
graph TB
Source[src/] --> Vite[Vite Build]
Vite --> Bundle[JS Bundle<br/>Code splitting]
Vite --> CSS[CSS Bundle<br/>Minified]
Vite --> Assets[Static Assets<br/>Optimized]
Bundle --> Dist[dist/]
CSS --> Dist
Assets --> Dist
Dist --> Server[Served by<br/>Express]
style Vite fill:#646CFF
style Dist fill:#10B981
Build Optimizations
- Code Splitting - Lazy load routes with
React.lazy() - Tree Shaking - Remove unused code
- Minification - Terser for JS, cssnano for CSS
- Asset Hashing - Cache busting with content hashes
- Compression - Gzip/Brotli (handled by Express)
Development
Prerequisites
- Node.js >= 20.0.0
- npm >= 9.0.0
Setup
# Install dependencies
npm install
# Start dev server
npm run dev
Environment Variables
The client uses hardcoded API URL (http://localhost:4820). For custom configuration, update lib/api.ts:
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4820';
Then create .env:
VITE_API_URL=http://localhost:4820
Hot Module Replacement (HMR)
Vite provides instant feedback on code changes:
sequenceDiagram
participant Dev as Developer
participant File as Source File
participant Vite
participant Browser
Dev->>File: Edit component
File->>Vite: File change detected
Vite->>Vite: Rebuild module
Vite->>Browser: Send HMR update
Browser->>Browser: Replace module
Browser->>Browser: Re-render component
Note over Browser: State preserved!
Performance
Metrics
- First Contentful Paint (FCP): < 0.5s
- Time to Interactive (TTI): < 1.5s
- Bundle Size: ~150KB gzipped (main chunk)
Optimization Techniques
graph TB
subgraph "Bundle Optimization"
Split[Code Splitting]
Lazy[Lazy Loading]
Tree[Tree Shaking]
end
subgraph "Runtime Optimization"
Memo[React.memo]
Callback[useCallback]
Virtual[Virtual Scrolling]
end
subgraph "Network Optimization"
Cache[HTTP Caching]
WS[WebSocket vs Polling]
Prefetch[Link Prefetching]
end
Split --> FastLoad[Fast Initial Load]
Lazy --> FastLoad
Tree --> FastLoad
Memo --> SmoothUI[Smooth UI]
Callback --> SmoothUI
Virtual --> SmoothUI
Cache --> LowLatency[Low Latency]
WS --> LowLatency
Prefetch --> LowLatency
Virtual Scrolling
For large lists (100+ sessions), implement virtual scrolling:
import { useVirtualizer } from '@tanstack/react-virtual';
function SessionList({ sessions }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: sessions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // estimated row height
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<SessionCard
key={sessions[virtualRow.index].id}
session={sessions[virtualRow.index]}
/>
))}
</div>
</div>
);
}
Accessibility
WCAG 2.1 Level AA Compliance
graph TB
subgraph "Accessibility Features"
Semantic[Semantic HTML]
ARIA[ARIA Labels]
Keyboard[Keyboard Navigation]
Focus[Focus Management]
Contrast[Color Contrast]
Screen[Screen Reader Support]
end
Semantic --> A11y[WCAG 2.1 AA]
ARIA --> A11y
Keyboard --> A11y
Focus --> A11y
Contrast --> A11y
Screen --> A11y
style A11y fill:#10B981
Implementation Checklist
- ✅ Semantic HTML5 elements (
<nav>,<main>,<article>) - ✅ ARIA labels on interactive elements
- ✅ Keyboard navigation (Tab, Enter, Escape)
- ✅ Focus indicators (outline on :focus)
- ✅ Color contrast ratio >= 4.5:1 for text
- ✅ Alternative text for icons (aria-label)
- ✅ Skip links for screen readers
Example
<button
onClick={handleDelete}
aria-label="Delete pricing rule"
className="focus:outline-blue-500"
>
<Trash2 aria-hidden="true" />
</button>
Summary
The client is a production-ready React application with:
- 🚀 Modern Stack - React 18, TypeScript, Vite, Tailwind
- ⚡ Real-time - WebSocket integration for live updates
- 🧪 Tested - Vitest + React Testing Library
- 📦 Optimized - Code splitting, tree shaking, lazy loading
- ♿ Accessible - WCAG 2.1 AA compliant
- 🎨 Maintainable - Clear architecture, type-safe, well-documented
For server documentation, see server/README.md.