Files
AgentHub/client/README.md
T
nntrivi2001 cb19d7e5d2 feat: file upload/download and pinned sessions, text size, git identity and clone
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):

- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
  /:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
  (git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
  the session menu, pinned first); text size setting (lib/fontSize.ts,
  Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
  routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().

Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:21:55 +07:00

1227 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Client Application
Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent monitoring.
![Claude Code](https://img.shields.io/badge/Claude_Code-orange?style=flat-square&logo=claude&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white)
![React](https://img.shields.io/badge/React-18.3-61DAFB?style=flat-square&logo=react&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Javascript](https://img.shields.io/badge/JavaScript-ES6-F7DF1E?style=flat-square&logo=javascript&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-6.1-646CFF?style=flat-square&logo=vite&logoColor=white)
![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-3.4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
![WebSocket](https://img.shields.io/badge/WebSocket-RFC_6455-010101?style=flat-square&logo=socketdotio&logoColor=white)
![i18next](https://img.shields.io/badge/i18next-22.4-7A42FF?style=flat-square&logo=i18next&logoColor=white)
![i18next Language Detector](https://img.shields.io/badge/i18next_Language_Detector-6.1-7A42FF?style=flat-square&logo=i18next&logoColor=white)
![Mermaid](https://img.shields.io/badge/Mermaid-10.2-ff3333?style=flat-square&logo=mermaid&logoColor=white)
![React Router](https://img.shields.io/badge/React_Router-6.28-CA4245?style=flat-square&logo=reactrouter&logoColor=white)
![Lucide](https://img.shields.io/badge/Lucide_Icons-0.474-F56565?style=flat-square&logo=lucide&logoColor=white)
![D3.js](https://img.shields.io/badge/D3.js-7-F9A03C?style=flat-square&logo=d3&logoColor=white)
![PostCSS](https://img.shields.io/badge/PostCSS-8.5-DD3A0A?style=flat-square&logo=postcss&logoColor=white)
![Autoprefixer](https://img.shields.io/badge/Autoprefixer-10.4-DD3735?style=flat-square&logo=autoprefixer&logoColor=white)
![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white)
![Prometheus](https://img.shields.io/badge/Prometheus-2.x-E6522C?style=flat-square&logo=prometheus&logoColor=white)
![Grafana](https://img.shields.io/badge/Grafana-10.x-F46800?style=flat-square&logo=grafana&logoColor=white)
![Vitest](https://img.shields.io/badge/Vitest-1.0-646CFF?style=flat-square&logo=vitest&logoColor=white)
![React Testing Library](https://img.shields.io/badge/React_Testing_Library-13.0-FF5733?style=flat-square&logo=testinglibrary&logoColor=white)
---
## 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 workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
**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 → Snapshots & 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 · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · 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).