9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
1227 lines
47 KiB
Markdown
1227 lines
47 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/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||
|
||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||
|
||
Other rules that fall out of this:
|
||
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
|
||
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
|
||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
|
||
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
|
||
- **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 (incl. Workflow) / Health tabs]
|
||
Sessions[Sessions]
|
||
Detail[SessionDetail]
|
||
Feed[ActivityFeed]
|
||
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 & 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
|
||
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), tool filter, search over workspace + session names, row menu
|
||
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: repo facts, prompt box (new session in a free pane slot), Recents with cost, Instructions / Memory / Context / Folder
|
||
│ │ ├── lanes/SessionResumeButton.tsx # The transcript page's explicit Resume (own-folder Claude sessions; warns, then a free pane) / Open pane
|
||
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
|
||
│ │ ├── Layout.tsx
|
||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||
│ │ ├── settings/ # Settings panels, among them:
|
||
│ │ │ ├── SettingsRow.tsx # Row primitives: SettingsSection, SettingsRow, SettingsSwitchRow, SettingsSegmented
|
||
│ │ │ ├── AiToolsSettings.tsx # Settings → Agents → AI tools: installed/available CLIs, enable/default, install/update
|
||
│ │ │ ├── WorktreeVisibilitySettings.tsx # Settings → Git → Worktrees: which untracked worktrees the sidebar lists
|
||
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
|
||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
|
||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||
│ │
|
||
│ ├── pages/ # Route pages
|
||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
|
||
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
|
||
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
|
||
│ │ ├── 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) + Replay tab (EventReplay) + Snapshot action
|
||
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
|
||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
|
||
│ │ ├── Settings.tsx
|
||
│ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
|
||
│ │ └── 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
|
||
│ │ ├── useLanes.ts # ONE shared lane list (GET /api/lanes + `lane_update` frames, refetch on reconnect) for the sidebar tree, status bar and Workspace page
|
||
│ │ ├── useNotifications.ts # Browser push notification triggers; a shown one plays its type's sound (`notifPrefs.sound`/`soundByType`, `playSound` in lib/notify.ts, WAVs in public/sounds/ generated by scripts/gen-notification-sounds.js)
|
||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||
│ │
|
||
│ ├── i18n/ # Internationalization (en / vi)
|
||
│ ├── 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/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
|
||
|
||
App --> Layout
|
||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||
Dashboard --> Workflows
|
||
|
||
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 (Settings → Claude Code → Overview), 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 workspace'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 workspace'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 (redirect → /?tab=analytics&sub=workflow)"]
|
||
CcConfig["/cc-config (redirect)"]
|
||
Lanes["/lanes (redirect)"]
|
||
Run["/run (redirect)"]
|
||
Workspace["/workspace?lane=<id> (home) · &run=<runId> (session)"]
|
||
Settings["/settings (tabs: dashboard | claude-code | ...)"]
|
||
NF["/* (NotFound)"]
|
||
|
||
Root --> Dashboard & Sessions & Detail & Feed & Workflows & CcConfig & Lanes & Run & Workspace & 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 Query Explorer is now the Agents tab on /sessions */}
|
||
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||
{/* 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={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||
{/* legacy path - Claude config now lives in a Settings tab */}
|
||
<Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} />
|
||
{/* legacy paths - lanes and runs are one page now */}
|
||
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
|
||
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
||
<Route path="workspace" element={<Workspace />} />
|
||
<Route path="settings/*" element={<SettingsHub />} />
|
||
<Route path="*" element={<NotFound />} />
|
||
</Route>
|
||
</Routes>
|
||
</BrowserRouter>
|
||
);
|
||
}
|
||
```
|
||
|
||
Every page — `Dashboard`, `History`, `Workspace` (the diff viewer), `SettingsHub`, `SessionDetail`, `SnapshotView` — is a `React.lazy` import in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so it loads on first visit instead of in the initial bundle; `main.tsx` starts the Workspace chunk early on a `/workspace` deep link. xterm and its addons load with the first terminal (`loadXterm` in `TerminalView.tsx`), the sidebar's add/destructive workspace modals on first open, and Sagi only while it is enabled (`SagiMount`). Only the active UI language is fetched (`i18n/index.ts`, one lazy chunk per language; `main.tsx` renders once it is in). `vite build` writes hidden source maps and a `.br`/`.gz` sibling of every text asset ≥ 1 KB. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||
|
||
### 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
|
||
```
|
||
|
||
A thrown error carries the server's `error.code` as `code` and the HTTP `status` (plus `runId` from a `409 ESESSIONLIVE` start, `auth` from `409 EGITAUTH`). A `200` that is not JSON — the SPA's `index.html` from a proxy or a dev server that missed the route — throws `code: "EBADRESPONSE"` with the translated `errors:badResponse` message instead of `Unexpected token '<'`. Pages surface a failure through `toast` (`lib/toast.ts`, rendered by `components/Toaster.tsx`; an error toast stays until dismissed) rather than `alert()`, and ask for confirmation through `confirmDialog`/`confirmDiscard` (`lib/confirm.ts`, rendered by `components/ConfirmHost.tsx`) rather than `window.confirm`.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
#### Tooltips (`components/Tooltip.tsx`)
|
||
|
||
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
|
||
app the same designed tooltip — there is no wrapper to add at the call site.
|
||
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
|
||
focus (immediately) it takes the element's native `title`, shows it as an
|
||
anchored bubble with an arrow, and puts the attribute back when the bubble
|
||
closes. Consequences worth knowing:
|
||
|
||
- **Write `title="…"` exactly as before.** That is the whole API. A control
|
||
keeps its native tooltip, and its accessible name, when JS has not run.
|
||
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
|
||
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
|
||
in light mode; it flips above/below, clamps to the viewport, and closes on
|
||
scroll, resize, pointer-down or `Escape`.
|
||
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
|
||
expanding a truncated **number** inside a line of text, but draws the same
|
||
surface so the two read as one thing.
|
||
|
||
#### MarkdownPreview (`components/MarkdownPreview.tsx`)
|
||
|
||
A `.md`/`.markdown`/`.mdx` file (`isMarkdown`) rendered for reading with
|
||
`react-markdown` + `remark-gfm` (tables, task lists, strikethrough). It sits
|
||
behind a **Preview** / **Raw** toggle in two places: the config explorer's
|
||
`FileViewer` and the Changes panel's whole-file view (`lane-file-preview`,
|
||
reset to Raw on every file switch; the editor still opens from Raw). Raw HTML
|
||
in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||
the `.md-preview` rules in `index.css`, built from theme tokens.
|
||
|
||
#### Metric tooltips (`common:insights.help.*`)
|
||
|
||
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
|
||
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
|
||
`vi`.
|
||
|
||
---
|
||
|
||
## 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).
|
||
|