Files
Claude-Code-Monitor/client
nntrivi2001 78f6e1be8e feat(lanes): Add Lane repo/worktree mode toggle, manual branch, folder browse
- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no
  branch fields) - the right choice for a main repo you want stage
  detection on. Worktree mode (default) keeps the existing provisioning
  flow but now requires a manually-typed branch name instead of deriving
  one from the title.
- POST /lanes/worktree accepts an optional `branch`, validated via
  `git check-ref-format --branch`; omitting it preserves the CLI's
  existing auto-derived-branch behavior.
- New GET /lanes/browse lists a directory's immediate subdirectories,
  backing a small folder-browse modal on both path fields - browsers
  cannot expose an absolute path from a native picker, so this is
  server-backed instead, consistent with the tool's local-first model.
2026-08-06 16:03:49 +07:00
..

Client Application

Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent monitoring.

Claude Code Node.js React TypeScript Javascript Vite Tailwind CSS WebSocket i18next i18next Language Detector Mermaid React Router Lucide D3.js PostCSS Autoprefixer ESLint Docker Podman Prometheus Grafana Vitest React Testing Library


Table of Contents


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
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

  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:

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 committed openapi.yaml at 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 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)

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 — 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:

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

  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

# 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.