mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Full product rename across 109 files (~834 occurrences): - Env vars: CLAUDEMAN_* → CODEMAN_* - Data dirs: ~/.claudeman/ → ~/.codeman/, ~/claudeman-cases/ → ~/codeman-cases/ - tmux prefix: claudeman- → codeman- - localStorage: claudeman-* → codeman-* - Package/CLI: claudeman → codeman - GitHub repo: Ark0N/Claudeman → Ark0N/Codeman - systemd service: claudeman-web → codeman-web - Class: ClaudemanApp → CodemanApp Migration infrastructure for seamless transition: - state-store.ts: auto-migrates data directories on startup - tmux-manager.ts: dual-prefix detection (legacy claudeman- sessions) - app.js: localStorage key migration (preserves old keys) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
389 lines
16 KiB
Markdown
389 lines
16 KiB
Markdown
# Performance Audit: First Page Load
|
|
|
|
**Date**: 2026-02-18
|
|
**Scope**: Browser first-load of Codeman 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 CodemanApp().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=<timestamp> ... (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
|
|
<!-- Before -->
|
|
<link rel="stylesheet" href="mobile.css?v=...">
|
|
|
|
<!-- After -->
|
|
<link rel="stylesheet" href="mobile.css?v=..." media="(max-width: 1023px)">
|
|
```
|
|
|
|
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
|
|
<!-- Before -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
|
|
|
<!-- After -->
|
|
<link rel="preload" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"></noscript>
|
|
```
|
|
|
|
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 `<style>` + skeleton HTML in `<body>` showing a dark header bar + terminal placeholder. Hidden by `app.js` once init completes:
|
|
|
|
```html
|
|
<style>
|
|
.skeleton { display: flex; flex-direction: column; height: 100vh; }
|
|
.skeleton-header { height: 40px; background: #111; border-bottom: 1px solid #222; }
|
|
.skeleton-terminal { flex: 1; background: #0d0d0d; }
|
|
.app-loaded .skeleton { display: none; }
|
|
</style>
|
|
<div class="skeleton">
|
|
<div class="skeleton-header"></div>
|
|
<div class="skeleton-terminal"></div>
|
|
</div>
|
|
```
|
|
|
|
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 `<div id="helpModal" class="modal"></div>`. 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 `<head>`. Load full `styles.css` asynchronously:
|
|
|
|
```html
|
|
<style>/* ~50 lines of critical CSS */</style>
|
|
<link rel="preload" href="styles.css?v=..." as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
```
|
|
|
|
---
|
|
|
|
## 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.
|