mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix: tab folder visibility controlled by Tall Tabs setting + cache busting
- Renamed "Two-Row Tabs" to "Tall Tabs (Name + Folder)" for clarity - Tab folder path now only rendered in JS when setting is ON (not just CSS hidden) - CSS specificity fix: mobile.css now matches .tabs-two-rows specificity (0,2,0) - Server settings sync: display keys seed from server on fresh localStorage - Bumped cache buster versions to 0.1534 to force browser reload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`)
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
@@ -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=<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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+28
-9
@@ -3721,9 +3721,10 @@ class ClaudemanApp {
|
|||||||
const minimizedCount = minimizedAgents?.size || 0;
|
const minimizedCount = minimizedAgents?.size || 0;
|
||||||
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
|
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 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(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${this.escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${this.escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${this.escapeHtml(name)} session" ${session.workingDir ? `title="${this.escapeHtml(session.workingDir)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${this.escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${this.escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${this.escapeHtml(name)} session" ${session.workingDir ? `title="${this.escapeHtml(session.workingDir)}"` : ''}>
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
@@ -9821,10 +9822,21 @@ class ClaudemanApp {
|
|||||||
applyTabWrapSettings() {
|
applyTabWrapSettings() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const defaults = this.getDefaultSettings();
|
const defaults = this.getDefaultSettings();
|
||||||
const twoRows = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
const deviceType = MobileDetection.getDeviceType();
|
||||||
|
// Two-row tabs disabled on mobile/tablet — not enough screen space
|
||||||
|
const twoRows = deviceType === 'desktop'
|
||||||
|
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
|
||||||
|
: false;
|
||||||
|
const prevTallTabs = this._tallTabsEnabled;
|
||||||
|
this._tallTabsEnabled = twoRows;
|
||||||
const tabsEl = document.getElementById('sessionTabs');
|
const tabsEl = document.getElementById('sessionTabs');
|
||||||
if (tabsEl) {
|
if (tabsEl) {
|
||||||
tabsEl.classList.toggle('tabs-two-rows', twoRows);
|
tabsEl.classList.toggle('tabs-two-rows', twoRows);
|
||||||
|
tabsEl.classList.toggle('tabs-show-folder', twoRows);
|
||||||
|
}
|
||||||
|
// Re-render tabs if folder visibility changed (folder spans are generated in JS)
|
||||||
|
if (prevTallTabs !== undefined && prevTallTabs !== twoRows) {
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9970,15 +9982,22 @@ class ClaudemanApp {
|
|||||||
'subagentTrackingEnabled', 'subagentActiveTabOnly',
|
'subagentTrackingEnabled', 'subagentActiveTabOnly',
|
||||||
'imageWatcherEnabled', 'ralphTrackerEnabled', 'tabTwoRows',
|
'imageWatcherEnabled', 'ralphTrackerEnabled', 'tabTwoRows',
|
||||||
]);
|
]);
|
||||||
const filteredAppSettings = {};
|
// Merge settings: non-display keys always sync from server,
|
||||||
|
// display keys only seed from server when localStorage has no value
|
||||||
|
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
|
||||||
|
const localSettings = this.loadAppSettingsFromStorage();
|
||||||
|
const merged = { ...localSettings };
|
||||||
for (const [key, value] of Object.entries(appSettings)) {
|
for (const [key, value] of Object.entries(appSettings)) {
|
||||||
if (!displayKeys.has(key)) {
|
if (displayKeys.has(key)) {
|
||||||
filteredAppSettings[key] = value;
|
// Display keys: only use server value as initial seed
|
||||||
|
if (!(key in localSettings)) {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-display keys: server always wins
|
||||||
|
merged[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Merge non-display settings with localStorage (server takes precedence)
|
|
||||||
const localSettings = this.loadAppSettingsFromStorage();
|
|
||||||
const merged = { ...localSettings, ...filteredAppSettings };
|
|
||||||
this.saveAppSettingsToStorage(merged);
|
this.saveAppSettingsToStorage(merged);
|
||||||
|
|
||||||
// Apply notification prefs from server if present (only if localStorage has none)
|
// Apply notification prefs from server if present (only if localStorage has none)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
<!-- DNS preconnect for CDN — saves ~100ms on first load -->
|
<!-- DNS preconnect for CDN — saves ~100ms on first load -->
|
||||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||||
<link rel="stylesheet" href="styles.css?v=0.1533">
|
<link rel="stylesheet" href="styles.css?v=0.1534">
|
||||||
<link rel="stylesheet" href="mobile.css?v=0.1533">
|
<link rel="stylesheet" href="mobile.css?v=0.1534">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
||||||
<!-- Defer CDN scripts — they no longer block initial HTML parsing/paint.
|
<!-- Defer CDN scripts — they no longer block initial HTML parsing/paint.
|
||||||
'defer' preserves execution order (xterm loads before fit addon). -->
|
'defer' preserves execution order (xterm loads before fit addon). -->
|
||||||
@@ -792,8 +792,8 @@
|
|||||||
|
|
||||||
<!-- Tab Bar Section -->
|
<!-- Tab Bar Section -->
|
||||||
<div class="settings-section-header">Tab Bar</div>
|
<div class="settings-section-header">Tab Bar</div>
|
||||||
<div class="settings-item" title="Allow tabs to wrap into two rows when there are many sessions">
|
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
|
||||||
<span class="settings-item-label">Two-Row Tabs</span>
|
<span class="settings-item-label">Tall Tabs (Name + Folder)</span>
|
||||||
<label class="switch switch-sm">
|
<label class="switch switch-sm">
|
||||||
<input type="checkbox" id="appSettingsTabTwoRows">
|
<input type="checkbox" id="appSettingsTabTwoRows">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
@@ -1474,6 +1474,6 @@
|
|||||||
<!-- Lines drawn dynamically -->
|
<!-- Lines drawn dynamically -->
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<script defer src="app.js?v=0.1533"></script>
|
<script defer src="app.js?v=0.1534"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -77,10 +77,13 @@ html.mobile-init .file-browser-panel {
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact session tabs */
|
/* Compact session tabs — .tabs-two-rows override needed to match
|
||||||
.session-tabs {
|
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
|
||||||
|
.session-tabs,
|
||||||
|
.session-tabs.tabs-two-rows {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
max-height: 52px;
|
max-height: 52px;
|
||||||
@@ -344,11 +347,14 @@ html.mobile-init .file-browser-panel {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ultra-compact session tabs */
|
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
|
||||||
.session-tabs {
|
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
|
||||||
|
.session-tabs,
|
||||||
|
.session-tabs.tabs-two-rows {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
max-height: 36px;
|
max-height: 36px;
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
||||||
.session-tab .tab-folder {
|
.session-tab .tab-folder {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -316,12 +317,11 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.tabs-show-folder .session-tab .tab-folder {
|
||||||
.session-tab .tab-folder {
|
display: block;
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tab .tab-close {
|
.session-tab .tab-close {
|
||||||
|
|||||||
Reference in New Issue
Block a user