diff --git a/CLAUDE.md b/CLAUDE.md index 00c46c68..7b4befe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1534 (must match `package.json` for npm publish) +**Version**: 0.1535 (must match `package.json` for npm publish) ## Project Overview diff --git a/docs/perf-audit-first-load.md b/docs/perf-audit-first-load.md new file mode 100644 index 00000000..50357b7b --- /dev/null +++ b/docs/perf-audit-first-load.md @@ -0,0 +1,388 @@ +# Performance Audit: First Page Load + +**Date**: 2026-02-18 +**Scope**: Browser first-load of Claudeman web UI (`/`) +**Method**: Static analysis by 4 parallel audit agents (server, frontend, SSE/xterm, asset pipeline) + +--- + +## Current State Summary + +### Payload Sizes (measured from live server, port 3000) + +| Asset | Raw Size | Gzip | Brotli | Lines | Render-Blocking? | +|-------|----------|------|--------|-------|-----------------| +| `index.html` | 82 KB | 15 KB | 15 KB | 1,479 | N/A (document) | +| `app.js` | 562 KB | 126 KB | 125 KB | 15,354 | No (`defer`) | +| `styles.css` | 154 KB | 25 KB | 27 KB | 8,199 | **YES** | +| `mobile.css` | 34 KB | 7 KB | 7 KB | 1,493 | **YES** (no media query!) | +| `xterm.css` (CDN) | 2 KB | 2 KB | — | — | **YES** (external CDN) | +| `xterm.min.js` (CDN) | 67 KB | 65 KB | — | — | No (`defer`) | +| `xterm-addon-fit` (CDN) | 1 KB | 1 KB | — | — | No (`defer`) | +| **Total local** | **832 KB** | **173 KB** | **174 KB** | | | +| **Total w/ CDN** | **~902 KB** | **~241 KB** | | | | + +**Server compression**: Brotli preferred (`Content-Encoding: br`), via `@fastify/compress` with threshold 1024. Compression is **on-the-fly per request** — no pre-compressed files exist. + +**HTTP headers verified**: `Cache-Control: public, max-age=3600`, weak ETags auto-generated by `@fastify/static`, `Vary: accept-encoding`, CSP + security headers present. + +### Request Waterfall on First Load (6-7 API calls!) + +``` +Browser hits / +├── index.html ............................ (82 KB document) +├── styles.css?v=0.1533 .................. (render-blocking CSS, 154 KB) +├── mobile.css?v=0.1533 .................. (render-blocking CSS, 34 KB — wasted on desktop!) +├── xterm.css (CDN) ...................... (render-blocking CSS — external!) +├── xterm.min.js (CDN, defer) ........... (67 KB, parallel download) +├── xterm-addon-fit.min.js (CDN, defer) .. (1 KB, parallel download) +├── app.js?v=0.1533 (defer) ............. (562 KB, parallel download) +│ +│ [FIRST PAINT blocked until ALL CSS downloaded + parsed] +│ +├── JS executes: new ClaudemanApp().init() +│ ├── initTerminal() ................... (SYNC: new Terminal() + terminal.open() → canvas creation) +│ ├── connectSSE() → /api/events ....... (SSE → fires 'init' with getLightState()) +│ ├── loadState() → /api/status ........ (DUPLICATE #1: same data as SSE init!) +│ ├── loadQuickStartCases() +│ │ ├── /api/settings ................ (settings fetch #1) +│ │ └── /api/cases?_t= ... (case list, cache-busted!) +│ ├── startSystemStatsPolling() → /api/system/stats (every 2s, starts immediately) +│ └── loadAppSettingsFromServer() → /api/settings (DUPLICATE #2: settings fetched again!) +``` + +**Total init API calls**: 6-7 requests, with **2 duplicates** (`/api/status` = SSE init, `/api/settings` fetched twice). + +### Critical Path Bottlenecks + +1. **3 render-blocking CSS files** (one from CDN, one wasted on desktop) +2. **Synchronous `terminal.open()`** blocks main thread during init (canvas creation) +3. **Double `handleInit()` execution** — SSE init + `/api/status` both call it, causing full state reset + cleanup twice within ~100ms +4. **`/api/settings` fetched twice** — once in `loadQuickStartCases()`, once in `loadAppSettingsFromServer()` +5. **No loading skeleton** — blank `#0a0a0a` screen until CSS+JS fully loaded +6. **12 modals pre-rendered** in HTML — ~600+ DOM elements, ~60KB of invisible HTML +7. **562KB monolith `app.js`** unminified — 1,525 comment lines (10%), 89 `console.*` statements, 23% whitespace +8. **No minification in build** — `cp -r` copies raw source to dist +9. **Stats polling starts immediately** — 2s interval even with no sessions +10. **Version query strings stale** — HTML has `?v=0.1533`, package.json is `0.1534` + +### What's Already Good + +- Only **1 xterm Terminal instance** shared across all sessions (buffer swapping on tab switch) +- Teammate terminals created **lazily** on window open (with `requestAnimationFrame` defer) +- Subagent windows use **HTML activity logs**, not additional Terminal instances +- `getLightState()` has a **1-second TTL cache** — no duplicate server-side computation +- SSE init sends **lightweight state** (no terminal buffers) — buffers fetched on-demand per tab +- Buffer hydration uses **chunked writes** (128KB chunks via `requestAnimationFrame`) — no UI jank +- `selectSession()` defers secondary panels via **`requestIdleCallback`** +- Buffer fetch is **tail-mode** (last 256KB only, not full 2MB) +- **System fonts only** — no web font downloads blocking paint +- All JS scripts use **`defer`** +- SSE reconnection has **proper exponential backoff** with timeout cleanup + +--- + +## Optimization Plan + +### Phase 1: Quick Wins (High Impact, Low Effort) + +#### 1.1 Add `media` attribute to mobile.css +**Impact**: HIGH — 34KB CSS stops blocking render on desktop +**Effort**: 1 line change +**File**: `src/web/public/index.html:14` + +```html + + + + + +``` + +The browser still downloads it (for potential resize) but won't block rendering on desktop. The `mobile.css` comment on line 4 says this was *intended* but never implemented. + +#### 1.2 Eliminate duplicate `/api/status` fetch + double `handleInit()` +**Impact**: HIGH — removes 1 redundant API call + eliminates double state reset (clearing 15+ Maps, 7+ timers, `cleanupAllFloatingWindows()`, double `renderSessionTabs()`, double async subagent restore chain) +**Effort**: Small +**Files**: `src/web/public/app.js:1554`, `app.js:3566-3574` + +The SSE `init` event (`server.ts:618`) already sends `getLightState()`. The `loadState()` at `app.js:1554` fetches identical data from `/api/status`. Both call `handleInit()` which does a full state reset — whichever arrives second **wipes all state from the first** and rebuilds from scratch. + +The `_initGeneration` guard (line 3373/3549) only protects the session-restore at the end, NOT the expensive full cleanup (lines 3389-3503). + +**Approach**: Remove `this.loadState()` from `init()`. Add a fallback timeout: + +```js +// In init(): +this.connectSSE(); +// Remove: this.loadState(); +this._initFallbackTimer = setTimeout(() => { + if (this._initGeneration === 0) this.loadState(); +}, 3000); +``` + +Clear the timer in `handleInit()`: +```js +handleInit(data) { + if (this._initFallbackTimer) { + clearTimeout(this._initFallbackTimer); + this._initFallbackTimer = null; + } + // ... rest of handleInit +} +``` + +#### 1.3 Deduplicate `/api/settings` fetch +**Impact**: MEDIUM — removes 1 redundant API call +**Effort**: Small +**Files**: `src/web/public/app.js:7341` (in `loadQuickStartCases`), `app.js:9964` (in `loadAppSettingsFromServer`) + +Both fetch `/api/settings`. Fetch it once, pass the result to both consumers: + +```js +// In init(): +const settingsPromise = fetch('/api/settings').then(r => r.json()); +this.loadQuickStartCases(null, settingsPromise); +this.loadAppSettingsFromServer(settingsPromise); +``` + +#### 1.4 Defer system stats polling +**Impact**: MEDIUM — removes 1 API call every 2s when idle +**Effort**: Small +**Files**: `src/web/public/app.js:1567`, `app.js:15261-15271` + +`fetchSystemStats()` already has a visibility guard (line 15282: skips if `#headerSystemStats` is `display: none`), but the interval still ticks. Move `startSystemStatsPolling()` out of `init()` — start it in `handleInit()` only when `data.sessions.length > 0`. + +#### 1.5 Preload xterm.css to unblock render +**Impact**: MEDIUM — external CDN CSS currently blocks first paint +**Effort**: 2 line change +**File**: `src/web/public/index.html:15` + +```html + + + + + + +``` + +Terminal won't display until xterm.js executes anyway, so the CSS doesn't need to block initial paint. + +#### 1.6 Fix stale version query strings +**Impact**: LOW — prevents serving cached stale assets after deploy +**Effort**: Small +**File**: COM script in CLAUDE.md + +The HTML references `?v=0.1533` while package.json is already at `0.1534`. The COM workflow should auto-update HTML version strings. Add to the COM script: + +```bash +# After incrementing version in package.json + CLAUDE.md: +sed -i "s/?v=[0-9.]*/?v=$NEW_VERSION/g" src/web/public/index.html +``` + +#### 1.7 Remove cache-busting from `/api/cases` +**Impact**: LOW — allows HTTP caching of case list +**Effort**: 1 line change +**File**: `src/web/public/app.js:7351` + +```js +// Before: +const res = await fetch('/api/cases?_t=' + Date.now()); +// After: +const res = await fetch('/api/cases'); +``` + +The case list rarely changes during a session. Let the browser cache it. + +--- + +### Phase 2: Medium Effort (High Impact) + +#### 2.1 Add loading skeleton +**Impact**: MEDIUM-HIGH — perceived performance improvement (instant visual structure) +**Effort**: Small-Medium +**File**: `src/web/public/index.html` + +Add minimal inline ` +
+
+
+
+``` + +In `app.js` init(), add `document.body.classList.add('app-loaded')` at the end. + +#### 2.2 Defer xterm.js terminal creation to after first paint +**Impact**: MEDIUM-HIGH — `terminal.open()` is the heaviest synchronous call in init +**Effort**: Medium +**Files**: `src/web/public/app.js:1545`, `app.js:1578-1639` + +```js +init() { + // ... mobile detection, visibility settings ... + document.documentElement.classList.remove('mobile-init'); + + // Show skeleton/header immediately, defer heavy terminal init + requestAnimationFrame(() => { + this.initTerminal(); + this.connectSSE(); + // ... rest of init + }); +} +``` + +Lets the browser paint the header/tabs before the terminal canvas is created. + +#### 2.3 Batch initial API calls into one endpoint +**Impact**: MEDIUM — reduces 4+ API calls to 1 +**Effort**: Medium +**Files**: `src/web/server.ts`, `src/web/public/app.js` + +Create `/api/init-bundle`: +```json +{ + "status": { /* getLightState() */ }, + "cases": [ /* case list */ ], + "settings": { /* user settings */ } +} +``` + +Replaces `/api/status` (fallback), `/api/cases`, `/api/settings`. Saves HTTP round trips and server-side work. + +#### 2.4 Lazy-create modals on first open +**Impact**: HIGH — removes ~600+ DOM elements from initial parse (~60KB of HTML) +**Effort**: Medium-High +**Files**: `src/web/public/index.html`, `src/web/public/app.js` + +12 modals pre-rendered in `index.html`: +- `helpModal` (lines 227-447) +- `sessionOptionsModal` (lines 448-714) — **266 lines alone** +- `appSettingsModal` (lines 715-900+) +- `createCaseModal`, `mobileCasePickerModal`, `ralphWizardModal`, `killAllModal`, `closeConfirmModal`, `savePresetModal`, `tokenStatsModal`, `filePreviewModal`, notification drawer + +**Approach**: Replace each modal's HTML with ``. On first open, inject full HTML via `createModalContent()`. Cache after creation. + +--- + +### Phase 3: Build Pipeline (Highest Impact) + +#### 3.1 Self-host xterm.js assets +**Impact**: MEDIUM — eliminates CDN dependency + latency, enables local caching +**Effort**: Low-Medium +**Files**: `src/web/public/index.html`, `package.json` build script + +```bash +# Build script addition: +mkdir -p dist/web/public/vendor +cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ +cp node_modules/xterm/lib/xterm.min.js dist/web/public/vendor/ +cp node_modules/@xterm/addon-fit/lib/xterm-addon-fit.min.js dist/web/public/vendor/ +``` + +Update HTML to reference `/vendor/xterm.min.js` etc. Removes render-blocking CDN CSS entirely. + +#### 3.2 Add esbuild minification to build +**Impact**: HIGH — ~38 KB compressed savings (16% of local payload) +**Effort**: Medium +**Files**: `package.json` (build script) + +Current build just does `cp -r src/web/public dist/web/`. No minification at all. + +**app.js specifics**: 1,525 comment lines (10%), 89 `console.*` statements, 23% whitespace. + +```bash +# Add to build script: +npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite +npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite +npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite +``` + +Expected: `app.js` 562KB → ~350KB minified → ~90KB gzip (from 126KB). `--drop:console` removes all 89 debug statements. + +Note: `app.js` is vanilla JS (not modules), so esbuild works directly as a minifier. + +#### 3.3 Pre-compress static assets at build time +**Impact**: MEDIUM — eliminates per-request CPU compression work +**Effort**: Low +**Files**: `package.json` build script, `src/web/server.ts` + +Currently `@fastify/compress` compresses on-the-fly for every request. Pre-compress at build time: + +```bash +# Build script: +for f in dist/web/public/*.{js,css,html}; do + gzip -9 -k "$f" + brotli -9 -k "$f" +done +``` + +Then configure `@fastify/static` with `preCompressed: true` (if supported) or serve pre-compressed files via custom logic. + +#### 3.4 Extract critical CSS inline +**Impact**: MEDIUM — eliminates render-blocking `styles.css` for first paint +**Effort**: Medium-High +**Files**: `src/web/public/styles.css`, `src/web/public/index.html` + +Identify ~2-3KB of CSS needed for first paint (body, header, tab bar, terminal container) and inline it in ``. Load full `styles.css` asynchronously: + +```html + + +``` + +--- + +## Impact Estimates + +| # | Optimization | First Paint | TTI | Effort | +|---|-------------|-------------|-----|--------| +| 1.1 | mobile.css media query | -50ms | — | 1 min | +| 1.2 | Remove duplicate fetch + double handleInit | — | -100-200ms | 15 min | +| 1.3 | Deduplicate settings fetch | — | -50ms | 10 min | +| 1.4 | Defer stats polling | — | -20ms | 10 min | +| 1.5 | Preload xterm.css | -100-300ms | — | 5 min | +| 1.6 | Fix stale version strings | cache correctness | — | 5 min | +| 1.7 | Remove cases cache-bust | — | -10ms | 1 min | +| 2.1 | Loading skeleton | perceived -500ms | — | 30 min | +| 2.2 | Defer terminal init | -50-100ms | -50ms | 30 min | +| 2.3 | Batch API endpoint | — | -100-200ms | 1 hr | +| 2.4 | Lazy modals | -30-50ms parse | -50ms | 2-3 hrs | +| 3.1 | Self-host xterm | -100-300ms | — | 20 min | +| 3.2 | Minify JS/CSS | -50-100ms parse | — | 30 min | +| 3.3 | Pre-compress assets | -10-30ms TTFB | — | 20 min | +| 3.4 | Critical CSS inline | -200-400ms | — | 2 hrs | + +**Combined estimate**: First paint **300-800ms faster**, TTI **200-500ms faster**. + +--- + +## Implementation Order (for implementation agent) + +Do these in order — each step is independently testable: + +1. **1.1** — mobile.css media query (1 line, instant win) +2. **1.5** — Preload xterm.css (2 lines, big render-blocking fix) +3. **1.2** — Remove duplicate `/api/status` + double handleInit +4. **1.3** — Deduplicate `/api/settings` fetch +5. **1.7** — Remove cache-busting from `/api/cases` +6. **3.1** — Self-host xterm.js (removes CDN dependency entirely) +7. **3.2** — Add esbuild minification to build +8. **1.4** — Defer stats polling +9. **1.6** — Fix stale version strings in COM workflow +10. **2.1** — Loading skeleton +11. **2.2** — Defer terminal init after first paint +12. **2.3** — Batch init API endpoint +13. **2.4** — Lazy modals (biggest refactor, do last) +14. **3.3** — Pre-compress assets (nice-to-have) +15. **3.4** — Critical CSS extraction (only if still needed after above) + +**Verification after each step**: Use Playwright to load the page with `waitUntil: 'domcontentloaded'`, measure first paint timing, check that the UI renders correctly with 3-4s wait for async data. diff --git a/package.json b/package.json index 6cbd975a..c5152b0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1534", + "version": "0.1535", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index e8a19f35..e59b80a0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3721,9 +3721,10 @@ class ClaudemanApp { const minimizedCount = minimizedAgents?.size || 0; const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : ''; - // Show folder name if session has a custom name (to still show the directory context) + // Show folder name if session has a custom name AND tall tabs setting is enabled const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : ''; - const showFolder = session.name && folderName && folderName !== name; + const tallTabsEnabled = this._tallTabsEnabled ?? false; + const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name; parts.push(`