4a0f99583d
The Workflows page rendered eleven charts of Claude Code harness internals (collaboration network, subagent effectiveness, patterns, model delegation, error propagation, concurrency, complexity, compaction). Too specialized to read and nothing to act on, so they are gone along with their i18n keys. What is left is what a reader can act on: headline stats, Workflow-tool runs, the spawning DAG, the tool flow and the per-session drill-in — plus a new first section for this dashboard's OWN pipelines. LanePipelinePanel renders a horizontally scrolling lane strip and the selected lane's PipelineMap, reusing the Workspace components so a stage looks identical on both pages. The panel is read-only: the console-never-writes-a-stage boundary applies here too, so every lane action stays on the Workspace page. The workflows API response shape is untouched. Closes #6
1151 lines
35 KiB
Markdown
1151 lines
35 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; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) 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
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph "Browser Runtime"
|
||
subgraph "React Application"
|
||
Router[React Router]
|
||
Layout[Layout Component]
|
||
|
||
subgraph "Pages"
|
||
Home[Dashboard<br/>Monitor / Analytics / Health tabs]
|
||
Sessions[Sessions]
|
||
Detail[SessionDetail]
|
||
Feed[ActivityFeed]
|
||
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 & Sessions & Detail & Feed & 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/ # Workflows page panels: lane pipelines + D3 analytics (6 files)
|
||
│ │
|
||
│ ├── pages/ # Route pages
|
||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
|
||
│ │ ├── 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
|
||
│ │ ├── 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
|
||
|
||
```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/>Analytics tab: tokens + heatmap + trends<br/>Health tab: SystemHealthTab]
|
||
Sessions[Sessions<br/>filterable table]
|
||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||
Feed[ActivityFeed<br/>streaming event log]
|
||
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
||
Settings[Settings<br/>pricing + notifications + hooks]
|
||
|
||
App --> Layout
|
||
Layout --> Dashboard & Sessions & Detail & Feed & 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 |
|
||
| `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:
|
||
|
||
```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)"]
|
||
Sessions["/sessions (tabs: sessions | events)"]
|
||
Detail["/sessions/:id"]
|
||
Feed["/activity (redirect)"]
|
||
Workflows["/workflows"]
|
||
CcConfig["/cc-config (redirect)"]
|
||
Run["/run"]
|
||
Settings["/settings (tabs: dashboard | claude-code | ...)"]
|
||
NF["/* (NotFound)"]
|
||
|
||
Root --> Dashboard & Sessions & Detail & Feed & 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="sessions" element={<History />} />
|
||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||
<Route path="workflows" element={<Workflows />} />
|
||
{/* legacy path - Claude config now lives in a Settings tab */}
|
||
<Route path="cc-config" element={<Navigate to="/settings?tool=claude-code" replace />} />
|
||
<Route path="run" element={<Run />} />
|
||
<Route path="settings" element={<SettingsHub />} />
|
||
<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, Sessions, Session detail, Activity feed,
|
||
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).
|
||
|