- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo status strip, Needs you, Mute / Clear alerts); SagiPanel removed. - Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu). - pty-attach: resize/write after a Windows attach exited, and node-pty's pipe errors, no longer throw out of the WebSocket handler. - restart-helper: the server started by an update logs to data/agenthub-server.log instead of discarding its output.
Client Application
Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent monitoring.
Table of Contents
- Overview
- Architecture
- Component Hierarchy
- State Management
- WebSocket Integration
- Routing
- API Client
- UI Components
- Utilities
- Testing
- Build & Deployment
- Development
- Performance
- 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;useThemehook — 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-themeon<html>(absent fordefault) selects a CSS-variable block inindex.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-baseisvar(--surface-3)and light--syn-plainisvar(--fg-primary), so a theme never restates them. A new theme is a block there plus an id inTHEMES(hooks/useTheme.ts) and a label incomponents/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 throughuseSyncExternalStore, never cached in the module, so every consumer and every tab agree. An inline<script>at the top ofclient/index.htmlapplies 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, defaultjetbrains) andagenthub.terminal.fontSize= 11|12|13|14|15|16|18|20 px (lib/fontSize.ts, default 13), picked in Settings → Appearance; a change firesagenthub-font-sizeonwindow, so an open terminal re-fits at once. Settings → Appearance → Text size (#text-size) setsagenthub.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 andindex.cssre-states everytext-[Npx]class in rem, so all text scales with it. All of these keys are synced to the account like the theme (PREF_KEYSinlib/userPrefs.tsandserver/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.cardis brighter than the page it sits on (surface-3,#fdfdfe), which is also what makes everyhover:bg-surface-Nlift 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→#ffffffneutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it. - Light
--accentis not the dark one: dark's#0090ffis 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 onaccent.--accentis 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(Tailwindbg-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-solidis simplyvar(--accent). --fg-mutedcarries 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/10andborder-status-danger/40compile tocolor-mix(… , rgb(var(--tint-base))), not torgb(… / 0.1)— seetint()intailwind.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-baseis 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 tobackgroundColor/borderColor:text-status-warning/80andring-accent/40stay genuinely translucent (they sit on a fill), and thesurfacefamily 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.
- The light Default is IntelliJ Light's palette (
-
React Router 6.28 - Client-side routing with nested layouts
-
WebSocket - Real-time event streaming from server
-
Lucide Icons - Modern, consistent icon set
graph TB
subgraph "Browser Runtime"
subgraph "React Application"
Router[React Router]
Layout[Layout Component]
subgraph "Pages"
Home[Dashboard<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:
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
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
sequenceDiagram
participant Router
participant Page
participant Component
participant API
participant WS
Router->>Page: Navigate (params from URL)
Page->>API: Fetch initial data
API-->>Page: Return data
Page->>Component: Pass data as props
Component->>Component: Render UI
Note over WS: Real-time updates
WS->>Page: Event via EventBus
Page->>Page: Update local state
Page->>Component: Re-render with new props
State Management
The client uses local component state and React hooks for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the data-scope store (lib/dataScope.ts): a lightweight app-wide store holding the current set of data sources (local plus any configured Remote Data Sources). Pages read it and append ?sources= to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the RemoteSources component (components/RemoteSources.tsx), which drives the /api/remote-sources CRUD/test/sync endpoints and reflects live remote_source.status WebSocket updates. When a sync finishes, stats pages refetch via lib/remoteDataEvents.ts (remote_data.updated, remote_source.status with ok, or remote import.progress complete).
Cursor sessions (informational): Settings surfaces a subtle note on the CLAUDE_HOME (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
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
- Initial Load: Page component fetches data via API client on mount (
useEffect) - URL Changes: React Router triggers re-render, page refetches data
- Real-time Updates: WebSocket events trigger state updates via
EventBus - User Actions: Click handlers call API, optimistically update local state
Example from SessionDetailPage:
function SessionDetailPage() {
const { sessionId } = useParams();
const [session, setSession] = useState(null);
const [agents, setAgents] = useState([]);
// Initial load
useEffect(() => {
fetchSession(sessionId).then(setSession);
fetchAgents(sessionId).then(setAgents);
}, [sessionId]);
// Real-time updates
useEffect(() => {
const unsubscribe = eventBus.on('agent.created', (agent) => {
if (agent.session_id === sessionId) {
setAgents(prev => [...prev, agent]);
}
});
return unsubscribe;
}, [sessionId]);
}
WebSocket Integration
WebSocket Lifecycle
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connecting: connect()
Connecting --> Connected: onopen
Connecting --> Disconnected: onerror
Connected --> Disconnected: onclose
Connected --> Connected: onmessage
Disconnected --> Connecting: auto-reconnect (3s)
note right of Connected
Heartbeat every 30s
Emit events to EventBus
end note
WebSocket Message Flow
sequenceDiagram
participant Server
participant WS as WebSocket<br/>Manager
participant Bus as Event Bus
participant Page as Page Component
participant UI
Server->>WS: { type: 'session.created', data: {...} }
WS->>WS: Parse JSON
WS->>Bus: emit('session.created', data)
Bus->>Page: callback(data)
Page->>Page: Update state
Page->>UI: Re-render
Event Types
Server broadcasts these event types over WebSocket:
| Event Type | Payload | Triggered By |
|---|---|---|
session.created |
Session object | SessionStart hook |
session.updated |
Session object | Any hook touching session |
agent.created |
Agent object | PreToolUse hook |
agent.updated |
Agent object | PostToolUse/Stop hooks |
tool.executed |
Tool execution record | PostToolUse hook |
notification.received |
Notification object | Notification hook |
remote_source.status |
{ id, status, error?, last_sync_at? } (status: idle/syncing/ok/error/deleted) |
Remote Data Source sync poller + /api/remote-sources routes |
remote_data.updated |
{ sourceId, source, label?, counters?, last_sync_at? } |
Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts session_created / session_updated (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
lane_hook_output |
{ laneId, hook, stream, line } |
One output line from a 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:
// lib/eventBus.ts
class EventBus {
private listeners = new Map<string, Set<Function>>();
on(event: string, callback: Function): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
// Return unsubscribe function
return () => this.listeners.get(event)?.delete(callback);
}
emit(event: string, data: any): void {
this.listeners.get(event)?.forEach(cb => cb(data));
}
}
export const eventBus = new EventBus();
Usage in components:
useEffect(() => {
const unsubscribe = eventBus.on('session.created', handleNewSession);
return unsubscribe; // Cleanup on unmount
}, []);
Routing
Route Structure
graph TB
Root["/"]
Dashboard["/ (Dashboard)"]
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
// 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
sequenceDiagram
participant User
participant UI
participant Router
participant Page
User->>UI: Click session row in Sessions table
UI->>Router: navigate('/sessions/123')
Router->>Page: Mount SessionDetail
Page->>Page: Read params.id = '123'
Page->>Page: Fetch session data
Page->>UI: Render agent tree + event timeline
Note over UI: In ActivityFeed, clicking a row<br/>expands the inline payload panel.<br/>The "Session →" button on each row<br/>navigates to /sessions/:id instead.
API Client
API Architecture
graph LR
Component[React Component] --> API[api.ts]
API --> Fetch[fetch API]
Fetch --> Server[Server :4820]
Server --> Response[JSON Response]
Response --> API
API --> Component
style API fill:#10B981
style Server fill:#3B82F6
API Client Structure
// lib/api.ts
const BASE_URL = 'http://localhost:4820';
class APIClient {
private async request(path: string, options?: RequestInit) {
const response = await fetch(`${BASE_URL}${path}`, options);
if (!response.ok) throw new Error(`API error: ${response.statusText}`);
return response.json();
}
// Sessions
getSessions() { return this.request('/api/sessions'); }
getSession(id: string) { return this.request(`/api/sessions/${id}`); }
// Agents
getAgents(sessionId: string) {
return this.request(`/api/sessions/${sessionId}/agents`);
}
getAgent(id: string) { return this.request(`/api/agents/${id}`); }
// Tools
getTools(agentId: string) {
return this.request(`/api/agents/${agentId}/tools`);
}
// Pricing
getPricingRules() { return this.request('/api/pricing'); }
createPricingRule(rule: PricingRule) {
return this.request('/api/pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule)
});
}
deletePricingRule(pattern: string) {
return this.request(`/api/pricing/${encodeURIComponent(pattern)}`, {
method: 'DELETE'
});
}
}
export const api = new APIClient();
API reference: the endpoints this client calls are fully documented by the server's OpenAPI 3.0.3 spec. With the dashboard running (default port
4820), explore them at/api/docs(interactive Swagger UI),/api/redoc(read-optimized ReDoc reference), or/api/openapi.json(raw spec). A committedopenapi.yamlat the repo root mirrors the live spec.
Error Handling
graph TB
Request[API Request]
FetchCall[Call fetch API]
NetworkError{Network Error?}
HTTPError{HTTP Error?}
Success[Return JSON]
Request --> FetchCall
FetchCall --> NetworkError
NetworkError -->|Yes| Throw1[Throw Network Error]
NetworkError -->|No| HTTPError
HTTPError -->|Yes| Throw2[Throw HTTP Error]
HTTPError -->|No| Success
style Throw1 fill:#EF4444
style Throw2 fill:#EF4444
style Success fill:#10B981
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:
interface SessionCardProps {
session: Session;
}
Visual Structure:
┌────────────────────────────────────────┐
│ 🟢 Session Title $0.45 │
│ claude-sonnet-4 │
│ Started: 2 hours ago │
│ Agents: 3 | Tools: 12 │
└────────────────────────────────────────┘
AgentCard
Shows agent type, status, tool usage, and cost breakdown.
Props:
interface AgentCardProps {
agent: Agent;
}
StatusBadge
Colored status pills for agents (AgentStatusBadge) and sessions (SessionStatusBadge). When a row is in the yellow Waiting overlay (awaiting_input_since set), an optional reason prop explains WHY: a hover tooltip carries the full explanation, and — unless compact is set — a small nested chip (icon + short label) renders inline. Card layouts (Kanban / Dashboard trees) pass compact so the chip never squeezes the card title; the Sessions table and session-detail header show the full chip:
awaiting_reason |
Label | Meaning |
|---|---|---|
notification |
Needs input | Blocked on a permission prompt / input request (urgent — amber) |
stop |
Turn done | Claude finished its reply; idle until the next prompt |
session_start |
At prompt | Fresh/resumed CLI sitting at an empty prompt |
interrupted |
Interrupted | Turn cut short — Esc or a recovered hook (urgent — amber) |
Props:
interface AgentStatusBadgeProps {
status: EffectiveAgentStatus;
pulse?: boolean;
reason?: AwaitingReason | null; // from agentAwaitingReason(agent)
compact?: boolean; // tooltip-only (no inline chip) for tight card layouts
}
Unknown/future server reasons degrade to a plain Waiting badge (normalizeAwaitingReason filters them to null). SessionDetail additionally renders a waiting-for-input banner (same reason + relative time) under the header via the shared REASON_ICONS map.
ToolCard
Displays tool execution details with timing and token usage.
Props:
interface ToolCardProps {
tool: ToolExecution;
}
EventTimeline
Chronological view of session events (hooks, tools, notifications).
graph TB
Timeline[EventTimeline]
Events[Event List]
Event1[SessionStart]
Event2[PreToolUse]
Event3[PostToolUse]
Event4[Notification]
Timeline --> Events
Events --> Event1
Events --> Event2
Events --> Event3
Events --> Event4
style Event1 fill:#10B981
style Event2 fill:#3B82F6
style Event3 fill:#8B5CF6
style Event4 fill:#F59E0B
ActivityFeed (pages/ActivityFeed.tsx)
Real-time streaming event log with pause/resume, pagination, and inline payload expansion.
UX interaction model:
flowchart LR
ROW["Event row\n(role=button)"] -->|click| EXPAND["Toggle EventDetail\n(inline payload)"]
ROW --> SESSBTN["Session → button\n(right edge)"]
SESSBTN -->|click + stopPropagation| NAV["/sessions/:id"]
EXPAND --> ED["EventDetail.tsx\nparsed fields + JSON blocks"]
style ROW fill:#1a1a28,stroke:#4f4f6a,color:#e4e4ed
style SESSBTN fill:#3B82F6,stroke:#60A5FA,color:#fff
style ED fill:#10B981,stroke:#34D399,color:#fff
style NAV fill:#8B5CF6,stroke:#A78BFA,color:#fff
- The entire row is clickable (keyboard accessible via
Enter/Space) and toggles theEventDetaildropdown. - 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-offwhere 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 orEscape. 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)
graph LR
subgraph "Formatting Functions"
fmt[fmt<br/>Number formatting]
fmtCost[fmtCost<br/>Currency formatting]
timeAgo[timeAgo<br/>Relative time]
end
Components[UI Components] --> fmt
Components --> fmtCost
Components --> timeAgo
fmt --> Output1["1,234,567"]
fmtCost --> Output2["$12.34"]
timeAgo --> Output3["2 hours ago"]
style fmt fill:#10B981
style fmtCost fill:#10B981
style timeAgo fill:#10B981
Function Signatures:
// Format large numbers with commas
export function fmt(n: number | null | undefined): string;
// Examples: fmt(1234) → "1,234"
// fmt(null) → "—"
// Format cost in dollars
export function fmtCost(cost: number | null | undefined): string;
// Examples: fmtCost(1.234) → "$1.23"
// fmtCost(0) → "$0.00"
// Relative time string
export function timeAgo(date: string | Date | null | undefined): string;
// Examples: timeAgo('2024-03-18T12:00:00Z') → "2 hours ago"
// timeAgo(null) → "—"
Type Definitions (lib/types.ts)
All TypeScript interfaces match server response shapes:
interface Session {
id: string;
session_id: string;
model: string;
status: 'active' | 'completed' | 'error' | 'abandoned';
total_cost: number;
created_at: string;
updated_at: string;
}
interface Agent {
id: number;
agent_id: string;
session_id: string;
agent_type: string;
status: 'working' | 'waiting' | 'completed' | 'error';
input_tokens: number;
output_tokens: number;
cost: number;
created_at: string;
}
interface ToolExecution {
id: number;
agent_id: string;
tool_name: string;
duration_ms: number;
success: boolean;
created_at: string;
}
Testing
Test Stack
- Vitest - Fast unit test runner (Vite-native)
- React Testing Library - Component testing
- jsdom - Browser environment simulation
Test Structure
client/src/
├── components/__tests__/
│ ├── AgentCard.test.tsx
│ ├── SessionCard.test.tsx
│ └── EventTimeline.test.tsx
│
├── pages/__tests__/
│ ├── screens.snapshot.test.tsx # render snapshots for every screen
│ └── __snapshots__/ # committed .snap baselines
│
└── lib/__tests__/
├── format.test.ts
├── eventBus.test.ts
└── api.test.ts
Running Tests
# Run all tests
npm test
# Watch mode
npm run test:watch
# Coverage report
npm run test:coverage
Example Test
// components/__tests__/SessionCard.test.tsx
import { render, screen } from '@testing-library/react';
import { SessionCard } from '../SessionCard';
test('renders session title and cost', () => {
const session = {
id: '1',
session_id: 'sess_123',
model: 'claude-sonnet-4',
total_cost: 1.23,
status: 'active',
created_at: '2024-03-18T12:00:00Z'
};
render(<SessionCard session={session} />);
expect(screen.getByText('sess_123')).toBeInTheDocument();
expect(screen.getByText('$1.23')).toBeInTheDocument();
});
Snapshot Testing
pages/__tests__/screens.snapshot.test.tsx renders every routed screen
(Dashboard, 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 —importOriginalkeeps non-apiexports real; - stubs
eventBus, push notifications, and the jsdom-missingResizeObserver/IntersectionObserver/matchMedia/scroll*APIs; - pins the clock (
vi.useFakeTimers) and timezone (TZ=UTC) so any rendered timestamps are stable.
When you change a screen intentionally, review the diff and regenerate the baselines:
cd client && npx vitest run -u src/pages/__tests__/screens.snapshot.test.tsx
Commit the updated .snap file alongside the change.
Build & Deployment
Development Build
npm run dev
Starts Vite dev server with HMR at http://localhost:5173
graph LR
Source[src/**/*.tsx] --> Vite[Vite Dev Server]
Vite --> HMR[Hot Module<br/>Replacement]
HMR --> Browser[Browser]
Browser -->|Changes| Vite
style Vite fill:#646CFF
Production Build
npm run build
Output: client/dist/ (optimized static files)
graph TB
Source[src/] --> Vite[Vite Build]
Vite --> Bundle[JS Bundle<br/>Code splitting]
Vite --> CSS[CSS Bundle<br/>Minified]
Vite --> Assets[Static Assets<br/>Optimized]
Bundle --> Dist[dist/]
CSS --> Dist
Assets --> Dist
Dist --> Server[Served by<br/>Express]
style Vite fill:#646CFF
style Dist fill:#10B981
Build Optimizations
- Code Splitting - Lazy load routes with
React.lazy() - Tree Shaking - Remove unused code
- Minification - Terser for JS, cssnano for CSS
- Asset Hashing - Cache busting with content hashes
- Compression - Gzip/Brotli (handled by Express)
Development
Prerequisites
- Node.js >= 20.0.0
- npm >= 9.0.0
Setup
# Install dependencies
npm install
# Start dev server
npm run dev
Environment Variables
The client uses hardcoded API URL (http://localhost:4820). For custom configuration, update lib/api.ts:
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4820';
Then create .env:
VITE_API_URL=http://localhost:4820
Hot Module Replacement (HMR)
Vite provides instant feedback on code changes:
sequenceDiagram
participant Dev as Developer
participant File as Source File
participant Vite
participant Browser
Dev->>File: Edit component
File->>Vite: File change detected
Vite->>Vite: Rebuild module
Vite->>Browser: Send HMR update
Browser->>Browser: Replace module
Browser->>Browser: Re-render component
Note over Browser: State preserved!
Performance
Metrics
- First Contentful Paint (FCP): < 0.5s
- Time to Interactive (TTI): < 1.5s
- Bundle Size: ~150KB gzipped (main chunk)
Optimization Techniques
graph TB
subgraph "Bundle Optimization"
Split[Code Splitting]
Lazy[Lazy Loading]
Tree[Tree Shaking]
end
subgraph "Runtime Optimization"
Memo[React.memo]
Callback[useCallback]
Virtual[Virtual Scrolling]
end
subgraph "Network Optimization"
Cache[HTTP Caching]
WS[WebSocket vs Polling]
Prefetch[Link Prefetching]
end
Split --> FastLoad[Fast Initial Load]
Lazy --> FastLoad
Tree --> FastLoad
Memo --> SmoothUI[Smooth UI]
Callback --> SmoothUI
Virtual --> SmoothUI
Cache --> LowLatency[Low Latency]
WS --> LowLatency
Prefetch --> LowLatency
Virtual Scrolling
For large lists (100+ sessions), implement virtual scrolling:
import { useVirtualizer } from '@tanstack/react-virtual';
function SessionList({ sessions }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: sessions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // estimated row height
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<SessionCard
key={sessions[virtualRow.index].id}
session={sessions[virtualRow.index]}
/>
))}
</div>
</div>
);
}
Accessibility
WCAG 2.1 Level AA Compliance
graph TB
subgraph "Accessibility Features"
Semantic[Semantic HTML]
ARIA[ARIA Labels]
Keyboard[Keyboard Navigation]
Focus[Focus Management]
Contrast[Color Contrast]
Screen[Screen Reader Support]
end
Semantic --> A11y[WCAG 2.1 AA]
ARIA --> A11y
Keyboard --> A11y
Focus --> A11y
Contrast --> A11y
Screen --> A11y
style A11y fill:#10B981
Implementation Checklist
- ✅ Semantic HTML5 elements (
<nav>,<main>,<article>) - ✅ ARIA labels on interactive elements
- ✅ Keyboard navigation (Tab, Enter, Escape)
- ✅ Focus indicators (outline on :focus)
- ✅ Color contrast ratio >= 4.5:1 for text
- ✅ Alternative text for icons (aria-label)
- ✅ Skip links for screen readers
Example
<button
onClick={handleDelete}
aria-label="Delete pricing rule"
className="focus:outline-blue-500"
>
<Trash2 aria-hidden="true" />
</button>
Summary
The client is a production-ready React application with:
- 🚀 Modern Stack - React 18, TypeScript, Vite, Tailwind
- ⚡ Real-time - WebSocket integration for live updates
- 🧪 Tested - Vitest + React Testing Library
- 📦 Optimized - Code splitting, tree shaking, lazy loading
- ♿ Accessible - WCAG 2.1 AA compliant
- 🎨 Maintainable - Clear architecture, type-safe, well-documented
For server documentation, see server/README.md.