57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
1155 lines
34 KiB
Markdown
1155 lines
34 KiB
Markdown
# Client Application
|
||
|
||
Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent monitoring.
|
||
|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||

|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
- [Overview](#overview)
|
||
- [Architecture](#architecture)
|
||
- [Component Hierarchy](#component-hierarchy)
|
||
- [State Management](#state-management)
|
||
- [WebSocket Integration](#websocket-integration)
|
||
- [Routing](#routing)
|
||
- [API Client](#api-client)
|
||
- [UI Components](#ui-components)
|
||
- [Utilities](#utilities)
|
||
- [Testing](#testing)
|
||
- [Build & Deployment](#build--deployment)
|
||
- [Development](#development)
|
||
- [Performance](#performance)
|
||
- [Accessibility](#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 for rapid UI development
|
||
- **React Router 6.28** - Client-side routing with nested layouts
|
||
- **WebSocket** - Real-time event streaming from server
|
||
- **Lucide Icons** - Modern, consistent icon set
|
||
|
||
```mermaid
|
||
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:
|
||
|
||
```mermaid
|
||
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
|
||
│ │
|
||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||
│ ├── App.tsx # Root component + router setup
|
||
│ ├── main.tsx # Entry point
|
||
│ └── index.css # Tailwind + custom utilities
|
||
│
|
||
├── public/ # Static assets (sw.js service worker)
|
||
├── index.html # HTML template
|
||
├── vite.config.ts # Vite + proxy config
|
||
├── tailwind.config.js # Custom dark theme
|
||
├── tsconfig.json # Strict TypeScript config
|
||
└── package.json
|
||
```
|
||
|
||
---
|
||
|
||
## Component Hierarchy
|
||
|
||
### Page Components
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```mermaid
|
||
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](../server/README.md#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
|
||
|
||
```mermaid
|
||
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
|
||
|
||
1. **Initial Load**: Page component fetches data via API client on mount (`useEffect`)
|
||
2. **URL Changes**: React Router triggers re-render, page refetches data
|
||
3. **Real-time Updates**: WebSocket events trigger state updates via `EventBus`
|
||
4. **User Actions**: Click handlers call API, optimistically update local state
|
||
|
||
Example from `SessionDetailPage`:
|
||
|
||
```typescript
|
||
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
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```mermaid
|
||
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 |
|
||
|
||
### EventBus Pattern
|
||
|
||
The `eventBus` is a simple pub/sub system:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
useEffect(() => {
|
||
const unsubscribe = eventBus.on('session.created', handleNewSession);
|
||
return unsubscribe; // Cleanup on unmount
|
||
}, []);
|
||
```
|
||
|
||
---
|
||
|
||
## Routing
|
||
|
||
### Route Structure
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```tsx
|
||
// 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
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```typescript
|
||
// 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 committed `openapi.yaml` at the repo root mirrors the live spec.
|
||
|
||
### Error Handling
|
||
|
||
```mermaid
|
||
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:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
interface ToolCardProps {
|
||
tool: ToolExecution;
|
||
}
|
||
```
|
||
|
||
#### EventTimeline
|
||
|
||
Chronological view of session events (hooks, tools, notifications).
|
||
|
||
```mermaid
|
||
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:**
|
||
|
||
```mermaid
|
||
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 the `EventDetail` dropdown.
|
||
- 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)
|
||
|
||
```mermaid
|
||
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:**
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
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
|
||
|
||
```bash
|
||
# Run all tests
|
||
npm test
|
||
|
||
# Watch mode
|
||
npm run test:watch
|
||
|
||
# Coverage report
|
||
npm run test:coverage
|
||
```
|
||
|
||
### Example Test
|
||
|
||
```tsx
|
||
// 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 — `importOriginal` keeps non-`api` exports real;
|
||
- stubs `eventBus`, push notifications, and the jsdom-missing
|
||
`ResizeObserver` / `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:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Starts Vite dev server with HMR at `http://localhost:5173`
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Output: `client/dist/` (optimized static files)
|
||
|
||
```mermaid
|
||
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
|
||
|
||
1. **Code Splitting** - Lazy load routes with `React.lazy()`
|
||
2. **Tree Shaking** - Remove unused code
|
||
3. **Minification** - Terser for JS, cssnano for CSS
|
||
4. **Asset Hashing** - Cache busting with content hashes
|
||
5. **Compression** - Gzip/Brotli (handled by Express)
|
||
|
||
---
|
||
|
||
## Development
|
||
|
||
### Prerequisites
|
||
|
||
- Node.js >= 20.0.0
|
||
- npm >= 9.0.0
|
||
|
||
### Setup
|
||
|
||
```bash
|
||
# 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`:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```mermaid
|
||
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:
|
||
|
||
```tsx
|
||
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
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```tsx
|
||
<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](../server/README.md).
|
||
|