Files
AgentHub/client
nntrivi2001 0cb5e41faf feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only
  dropped it in memory, so its next update (or a reload) brought the alert
  straight back. An error now alerts on the transition into error only;
  cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay
  quiet until they recover and fail anew.
- Quips, greetings, notifications and Ask answers move to i18n (EN/VI);
  Ask understands Vietnamese keywords and names the sessions it counts.
- The pet goes by the worn pet's name; click it in the panel to rename
  (agenthub.pet.names, synced to the account).
- Bubbles name the session they are about, with an Open button (never a
  resume); a burst of the same event coalesces into one count.
- Moments: a new session waves, a finish hops, a subagent peeks, a wait taps,
  an error flinches — on Sagi, Codex sprites and still images. Idle fidgets,
  a once-per-session greeting, a "Needs you" list, an amber waiting dot.
- Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so
  it shows while the panel or chat is open.
- Panel drops Run Claude / Activity / Sessions / Errored.
- Ask hands off to a chat window (PetChat) on the default AI tool
  (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an
  off-screen attached terminal; no new server route.

Verified on HEAD + this diff in a clean worktree: tsc -b, the full client
suite (1334/1335; the one failure, ConversationView.follow, is flaky under
load and passes 3/3 alone) and server me-prefs.test.js.

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

Client Application

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

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


Table of Contents


Overview

The client is a single-page application (SPA) built with modern web technologies:

  • React 18.3 - Component-based UI with hooks and concurrent features

  • TypeScript 5.7 - Full type safety across components, utilities, and API contracts

  • Vite 6.1 - Lightning-fast HMR during development, optimized production builds

  • Tailwind CSS 3.4 - Utility-first CSS framework; colors are CSS-variable-backed tokens (darkMode: "class") so a Dark/Light/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 per-browser preference: agenthub.terminal.font = jetbrains|maple|system (lib/terminalFont.ts, default jetbrains), picked in Settings → Appearance and read when a terminal opens

    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

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

  1. Initial Load: Page component fetches data via API client on mount (useEffect)
  2. URL Changes: React Router triggers re-render, page refetches data
  3. Real-time Updates: WebSocket events trigger state updates via EventBus
  4. User Actions: Click handlers call API, optimistically update local state

Example from SessionDetailPage:

function SessionDetailPage() {
  const { sessionId } = useParams();
  const [session, setSession] = useState(null);
  const [agents, setAgents] = useState([]);
  
  // Initial load
  useEffect(() => {
    fetchSession(sessionId).then(setSession);
    fetchAgents(sessionId).then(setAgents);
  }, [sessionId]);
  
  // Real-time updates
  useEffect(() => {
    const unsubscribe = eventBus.on('agent.created', (agent) => {
      if (agent.session_id === sessionId) {
        setAgents(prev => [...prev, agent]);
      }
    });
    return unsubscribe;
  }, [sessionId]);
}

WebSocket Integration

WebSocket Lifecycle

stateDiagram-v2
    [*] --> Disconnected
    Disconnected --> Connecting: connect()
    Connecting --> Connected: onopen
    Connecting --> Disconnected: onerror
    Connected --> Disconnected: onclose
    Connected --> Connected: onmessage
    Disconnected --> Connecting: auto-reconnect (3s)
    
    note right of Connected
        Heartbeat every 30s
        Emit events to EventBus
    end note

WebSocket Message Flow

sequenceDiagram
    participant Server
    participant WS as WebSocket<br/>Manager
    participant Bus as Event Bus
    participant Page as Page Component
    participant UI
    
    Server->>WS: { type: 'session.created', data: {...} }
    WS->>WS: Parse JSON
    WS->>Bus: emit('session.created', data)
    Bus->>Page: callback(data)
    Page->>Page: Update state
    Page->>UI: Re-render

Event Types

Server broadcasts these event types over WebSocket:

Event Type Payload Triggered By
session.created Session object SessionStart hook
session.updated Session object Any hook touching session
agent.created Agent object PreToolUse hook
agent.updated Agent object PostToolUse/Stop hooks
tool.executed Tool execution record PostToolUse hook
notification.received Notification object Notification hook
remote_source.status { id, status, error?, last_sync_at? } (status: idle/syncing/ok/error/deleted) Remote Data Source sync poller + /api/remote-sources routes
remote_data.updated { sourceId, source, label?, counters?, last_sync_at? } Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts session_created / session_updated (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately
lane_hook_output { laneId, hook, stream, line } One output line from a 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 committed openapi.yaml at the repo root mirrors the live spec.

Error Handling

graph TB
    Request[API Request]
    FetchCall[Call fetch API]
    NetworkError{Network Error?}
    HTTPError{HTTP Error?}
    Success[Return JSON]
    
    Request --> FetchCall
    FetchCall --> NetworkError
    NetworkError -->|Yes| Throw1[Throw Network Error]
    NetworkError -->|No| HTTPError
    HTTPError -->|Yes| Throw2[Throw HTTP Error]
    HTTPError -->|No| Success
    
    style Throw1 fill:#EF4444
    style Throw2 fill:#EF4444
    style Success fill:#10B981

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

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 — importOriginal keeps non-api exports real;
  • stubs eventBus, push notifications, and the jsdom-missing ResizeObserver / IntersectionObserver / matchMedia / scroll* APIs;
  • pins the clock (vi.useFakeTimers) and timezone (TZ=UTC) so any rendered timestamps are stable.

When you change a screen intentionally, review the diff and regenerate the baselines:

cd client && npx vitest run -u src/pages/__tests__/screens.snapshot.test.tsx

Commit the updated .snap file alongside the change.


Build & Deployment

Development Build

npm run dev

Starts Vite dev server with HMR at http://localhost:5173

graph LR
    Source[src/**/*.tsx] --> Vite[Vite Dev Server]
    Vite --> HMR[Hot Module<br/>Replacement]
    HMR --> Browser[Browser]
    Browser -->|Changes| Vite
    
    style Vite fill:#646CFF

Production Build

npm run build

Output: client/dist/ (optimized static files)

graph TB
    Source[src/] --> Vite[Vite Build]
    Vite --> Bundle[JS Bundle<br/>Code splitting]
    Vite --> CSS[CSS Bundle<br/>Minified]
    Vite --> Assets[Static Assets<br/>Optimized]
    
    Bundle --> Dist[dist/]
    CSS --> Dist
    Assets --> Dist
    
    Dist --> Server[Served by<br/>Express]
    
    style Vite fill:#646CFF
    style Dist fill:#10B981

Build Optimizations

  1. Code Splitting - Lazy load routes with React.lazy()
  2. Tree Shaking - Remove unused code
  3. Minification - Terser for JS, cssnano for CSS
  4. Asset Hashing - Cache busting with content hashes
  5. Compression - Gzip/Brotli (handled by Express)

Development

Prerequisites

  • Node.js >= 20.0.0
  • npm >= 9.0.0

Setup

# Install dependencies
npm install

# Start dev server
npm run dev

Environment Variables

The client uses hardcoded API URL (http://localhost:4820). For custom configuration, update lib/api.ts:

const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4820';

Then create .env:

VITE_API_URL=http://localhost:4820

Hot Module Replacement (HMR)

Vite provides instant feedback on code changes:

sequenceDiagram
    participant Dev as Developer
    participant File as Source File
    participant Vite
    participant Browser
    
    Dev->>File: Edit component
    File->>Vite: File change detected
    Vite->>Vite: Rebuild module
    Vite->>Browser: Send HMR update
    Browser->>Browser: Replace module
    Browser->>Browser: Re-render component
    
    Note over Browser: State preserved!

Performance

Metrics

  • First Contentful Paint (FCP): < 0.5s
  • Time to Interactive (TTI): < 1.5s
  • Bundle Size: ~150KB gzipped (main chunk)

Optimization Techniques

graph TB
    subgraph "Bundle Optimization"
        Split[Code Splitting]
        Lazy[Lazy Loading]
        Tree[Tree Shaking]
    end
    
    subgraph "Runtime Optimization"
        Memo[React.memo]
        Callback[useCallback]
        Virtual[Virtual Scrolling]
    end
    
    subgraph "Network Optimization"
        Cache[HTTP Caching]
        WS[WebSocket vs Polling]
        Prefetch[Link Prefetching]
    end
    
    Split --> FastLoad[Fast Initial Load]
    Lazy --> FastLoad
    Tree --> FastLoad
    
    Memo --> SmoothUI[Smooth UI]
    Callback --> SmoothUI
    Virtual --> SmoothUI
    
    Cache --> LowLatency[Low Latency]
    WS --> LowLatency
    Prefetch --> LowLatency

Virtual Scrolling

For large lists (100+ sessions), implement virtual scrolling:

import { useVirtualizer } from '@tanstack/react-virtual';

function SessionList({ sessions }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: sessions.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100, // estimated row height
  });
  
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <SessionCard
            key={sessions[virtualRow.index].id}
            session={sessions[virtualRow.index]}
          />
        ))}
      </div>
    </div>
  );
}

Accessibility

WCAG 2.1 Level AA Compliance

graph TB
    subgraph "Accessibility Features"
        Semantic[Semantic HTML]
        ARIA[ARIA Labels]
        Keyboard[Keyboard Navigation]
        Focus[Focus Management]
        Contrast[Color Contrast]
        Screen[Screen Reader Support]
    end
    
    Semantic --> A11y[WCAG 2.1 AA]
    ARIA --> A11y
    Keyboard --> A11y
    Focus --> A11y
    Contrast --> A11y
    Screen --> A11y
    
    style A11y fill:#10B981

Implementation Checklist

  • ✅ Semantic HTML5 elements (<nav>, <main>, <article>)
  • ✅ ARIA labels on interactive elements
  • ✅ Keyboard navigation (Tab, Enter, Escape)
  • ✅ Focus indicators (outline on :focus)
  • ✅ Color contrast ratio >= 4.5:1 for text
  • ✅ Alternative text for icons (aria-label)
  • ✅ Skip links for screen readers

Example

<button
  onClick={handleDelete}
  aria-label="Delete pricing rule"
  className="focus:outline-blue-500"
>
  <Trash2 aria-hidden="true" />
</button>

Summary

The client is a production-ready React application with:

  • 🚀 Modern Stack - React 18, TypeScript, Vite, Tailwind
  • ⚡ Real-time - WebSocket integration for live updates
  • 🧪 Tested - Vitest + React Testing Library
  • 📦 Optimized - Code splitting, tree shaking, lazy loading
  • ♿ Accessible - WCAG 2.1 AA compliant
  • 🎨 Maintainable - Clear architecture, type-safe, well-documented

For server documentation, see server/README.md.