mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Extract 8 modules from the 15K-line app.js monolith:
- constants.js: shared constants, timing, escapeHtml(), extractSyncSegments()
- mobile-handlers.js: MobileDetection, KeyboardHandler, SwipeHandler
- voice-input.js: DeepgramProvider, VoiceInput
- notification-manager.js: NotificationManager class
- keyboard-accessory.js: KeyboardAccessoryBar, FocusTrap
- api-client.js: _api(), _apiJson(), _apiPost(), _apiDelete() helpers
- subagent-windows.js: 13 subagent window methods (open, close, drag, lines)
- vendor/xterm-zerolag-input.js: IIFE build replacing inlined copy
app.js reduced from ~15,200 to ~11,500 lines. All modules use global
scope with <script defer> ordering. Prototype extensions use
Object.assign(CodemanApp.prototype, {...}) pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1144 lines
46 KiB
Markdown
1144 lines
46 KiB
Markdown
# Phase 5: Frontend Modularization — Implementation Plan
|
||
|
||
**Date**: 2026-03-01
|
||
**Prerequisites**: Phase 1-4 complete (utils cleanup, CleanupManager/Debouncer, route extraction, domain splitting)
|
||
**Goal**: Split the 15,196-line `app.js` monolith into focused, independently-loadable modules while preserving the proven `Object.assign(CodemanApp.prototype, ...)` pattern from `ralph-wizard.js`.
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Current State Analysis](#1-current-state-analysis)
|
||
2. [Architectural Decisions](#2-architectural-decisions)
|
||
3. [Module Dependency Graph](#3-module-dependency-graph)
|
||
4. [Step 1: Extract Constants & Utilities](#step-1-extract-constants--utilities)
|
||
5. [Step 2: Extract Mobile Handlers](#step-2-extract-mobile-handlers)
|
||
6. [Step 3: Extract Voice Input](#step-3-extract-voice-input)
|
||
7. [Step 4: Extract NotificationManager](#step-4-extract-notificationmanager)
|
||
8. [Step 5: Extract FocusTrap & KeyboardAccessoryBar](#step-5-extract-focustrap--keyboardaccessorybar)
|
||
9. [Step 6: Replace Inlined xterm-zerolag-input](#step-6-replace-inlined-xterm-zerolag-input)
|
||
10. [Step 7: Create ApiClient Wrapper](#step-7-create-apiclient-wrapper)
|
||
11. [Step 8: Extract Subagent Window Manager](#step-8-extract-subagent-window-manager)
|
||
12. [Execution Order & Dependencies](#execution-order--dependencies)
|
||
13. [Validation Checklist](#validation-checklist)
|
||
14. [Risk Assessment](#risk-assessment)
|
||
|
||
---
|
||
|
||
## Safety Constraints
|
||
|
||
Before starting ANY work, read and follow these rules:
|
||
|
||
1. **Never run `npx vitest run`** (full suite) — it kills tmux sessions. You are running inside a Codeman-managed tmux session.
|
||
2. **Never test on port 3000** — the live dev server runs there. Tests use ports 3150+.
|
||
3. **After TypeScript changes**: Run `tsc --noEmit` to verify type checking passes.
|
||
4. **Before considering done**: Run `npm run lint` and `npm run format:check` to ensure CI passes.
|
||
5. **Never kill tmux sessions** — check `echo $CODEMAN_MUX` first.
|
||
6. **Frontend changes require browser verification**: After each step, use Playwright to load the page and verify the UI renders. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values.
|
||
7. **Cache busting**: Update the `?v=` query strings in `index.html` for any modified/new JS files after each step.
|
||
8. **Verify dev server**: `npx tsx src/index.ts web --port 3099 &` on a non-production port, confirm `curl -s http://localhost:3099/api/status | jq .status` returns `"ok"`, then kill the background process.
|
||
|
||
---
|
||
|
||
## 1. Current State Analysis
|
||
|
||
### File Structure (Before)
|
||
|
||
```
|
||
src/web/public/
|
||
├── app.js (15,196 lines — EVERYTHING)
|
||
├── ralph-wizard.js (1,037 lines — already extracted)
|
||
├── index.html (1,040 lines)
|
||
├── styles.css (main styling)
|
||
├── mobile.css (responsive overrides)
|
||
├── sw.js (service worker)
|
||
├── manifest.json (PWA manifest)
|
||
├── upload.html (screenshot upload)
|
||
└── vendor/ (xterm.js + addons)
|
||
```
|
||
|
||
### app.js Section Map
|
||
|
||
| Section | Lines | Size | Standalone? |
|
||
|---------|-------|------|-------------|
|
||
| Web Push utils (`urlBase64ToUint8Array`), `scheduleBackground`, constants | 1-159 | 159 | Yes |
|
||
| `MobileDetection` object | 168-271 | ~104 | Yes |
|
||
| `getEventCoords()` function | 273-285 | 13 | Yes |
|
||
| `KeyboardHandler` object | 292-567 | ~276 | No (refs `app`, `KeyboardAccessoryBar`, `MobileDetection`) |
|
||
| `SwipeHandler` object | 569-628 | ~60 | No (refs `app`) |
|
||
| `DeepgramProvider` object | 631-841 | 211 | Yes (WebSocket-only) |
|
||
| `VoiceInput` object | 842-1479 | 638 | No (refs `app`, `DeepgramProvider`) |
|
||
| `KeyboardAccessoryBar` object | 1480-1688 | ~209 | No (refs `app`, `MobileDetection`) |
|
||
| `FocusTrap` class | 1689-1748 | 60 | Yes |
|
||
| xterm-zerolag-input (inlined copy) | 1756-2153 | ~398 | No (refs xterm internals) |
|
||
| `extractSyncSegments()` | 2173-2215 | 43 | Yes |
|
||
| `NotificationManager` class | 2218-2663 | ~446 | No (refs `app`, `MobileDetection`; has own `escapeHtml` method) |
|
||
| **`CodemanApp` class | 2665-15176 | **12,512** | No (depends on all above) |
|
||
| Initialization + export | 15178-15196 | 19 | — |
|
||
|
||
### Cross-Reference Pattern
|
||
|
||
All top-level objects use `typeof X !== 'undefined'` guards for optional dependencies. This pattern enables **graceful degradation** if a module fails to load and must be preserved in extracted files.
|
||
|
||
```javascript
|
||
// Pattern used everywhere — must be preserved:
|
||
if (typeof app !== 'undefined') app.someMethod();
|
||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.show();
|
||
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice()) { ... }
|
||
```
|
||
|
||
### escapeHtml — Two Independent Copies
|
||
|
||
There are **two** `escapeHtml` methods in app.js:
|
||
|
||
1. **`NotificationManager.escapeHtml()`** (line 2659) — instance method, used internally by `NotificationManager` at lines 2461-2467
|
||
2. **`CodemanApp.escapeHtml()`** (line 15172) — instance method with static lookup maps (`_htmlEscapeMap`, `_htmlEscapePattern`) for performance. Used ~70 times throughout CodemanApp, and by `ralph-wizard.js` (3 calls via `this.escapeHtml`)
|
||
|
||
These are independent. Each class owns its own copy. The plan extracts a shared global `escapeHtml()` in `constants.js` and updates both classes + `ralph-wizard.js` to use it.
|
||
|
||
### Proven Extraction Pattern (ralph-wizard.js)
|
||
|
||
The `ralph-wizard.js` extraction established the pattern all further extractions will follow:
|
||
|
||
```javascript
|
||
// File loaded after app.js via <script defer src="ralph-wizard.js">
|
||
Object.assign(CodemanApp.prototype, {
|
||
methodName() { /* has access to `this` = CodemanApp instance */ },
|
||
anotherMethod() { ... },
|
||
});
|
||
```
|
||
|
||
**Key properties of this pattern:**
|
||
- No import/export — uses globals (vanilla JS, no bundler)
|
||
- `Object.assign` adds methods to prototype at load time
|
||
- Loaded with `defer` after `app.js` — guarantees `CodemanApp` exists
|
||
- Zero circular dependencies (one-way from module → CodemanApp prototype)
|
||
- HTML controls load order via `<script defer>` tag ordering
|
||
|
||
---
|
||
|
||
## 2. Architectural Decisions
|
||
|
||
### Decision 1: Vanilla JS + Script Tags (No Bundler)
|
||
|
||
**Rationale**: The frontend is a vanilla JS application with no build step. Introducing a bundler (webpack, Vite, rollup) would:
|
||
- Add build complexity and tooling dependencies
|
||
- Break the current `npm run dev` workflow (just serves files directly)
|
||
- Require source maps for debugging
|
||
- Conflict with the hot-reload-free development model
|
||
|
||
**Alternative considered**: ES modules with `<script type="module">`. Rejected because:
|
||
- Would require rewriting all globals as imports/exports
|
||
- `typeof X !== 'undefined'` guards would need replacement
|
||
- Service worker and `index.html` inline scripts would need adaptation
|
||
- Bigger migration risk for limited benefit at this stage
|
||
|
||
**Decision**: Continue with `<script defer>` tags and global objects. Each extracted file defines a global (object, class, or set of functions) that subsequent scripts can access. This matches `ralph-wizard.js`.
|
||
|
||
### Decision 2: Extraction Scope
|
||
|
||
Extract **pre-CodemanApp top-level objects** first (Steps 1-6), then **CodemanApp methods** using `Object.assign` (Steps 7-8). Do NOT attempt to break up the CodemanApp constructor or its core session/terminal management in this phase — that's a Phase 6+ effort requiring more invasive changes.
|
||
|
||
### Decision 3: Module Granularity
|
||
|
||
Target **5-8 new files** at 100-700 lines each. Avoid both micro-modules (<50 lines, not worth the HTTP request) and oversized modules (>1000 lines, defeats purpose). Each file should represent a **cohesive domain** with clear boundaries.
|
||
|
||
### Decision 4: File Naming Convention
|
||
|
||
Follow existing convention: `kebab-case.js` in `src/web/public/`. No subdirectories (keeps serving simple).
|
||
|
||
---
|
||
|
||
## 3. Module Dependency Graph
|
||
|
||
```
|
||
index.html (load order)
|
||
│
|
||
┌───────────┤ (defer scripts, in order)
|
||
│ │
|
||
vendor/xterm*.js constants.js (NEW)
|
||
│ │
|
||
│ mobile-handlers.js (NEW)
|
||
│ │
|
||
│ voice-input.js (NEW)
|
||
│ │
|
||
│ notification-manager.js (NEW)
|
||
│ │
|
||
│ keyboard-accessory.js (NEW)
|
||
│ │
|
||
│ │ (xterm-zerolag-input — inline or import from packages/)
|
||
│ │
|
||
├───────────┤
|
||
│ │
|
||
│ app.js (REDUCED — ~10,500 lines → ~8,500 lines)
|
||
│ │
|
||
│ ralph-wizard.js (existing)
|
||
│ │
|
||
│ api-client.js (NEW — Object.assign to prototype)
|
||
│ │
|
||
│ subagent-windows.js (NEW — Object.assign to prototype)
|
||
└───────────┘
|
||
```
|
||
|
||
**Key constraint**: Each script can only reference globals defined by scripts loaded BEFORE it. This is enforced by `<script defer>` ordering in `index.html`.
|
||
|
||
---
|
||
|
||
## Step 1: Extract Constants & Utilities
|
||
|
||
**New file**: `src/web/public/constants.js`
|
||
**Lines moved from app.js**: 1-159 (constants), 273-285 (`getEventCoords`), 2173-2215 (`extractSyncSegments`)
|
||
**Estimated size**: ~230 lines
|
||
**Time**: ~1 hour
|
||
|
||
### What Moves
|
||
|
||
**All top-level constants** (lines 1-159):
|
||
|
||
```javascript
|
||
// constants.js — Shared constants and utility functions for Codeman frontend
|
||
|
||
// Web Push
|
||
function urlBase64ToUint8Array(base64String) { ... }
|
||
|
||
// Timing constants
|
||
const DEFAULT_SCROLLBACK = 5000;
|
||
const STUCK_THRESHOLD_DEFAULT_MS = 600000;
|
||
const GROUPING_TIMEOUT_MS = 5000;
|
||
const NOTIFICATION_LIST_CAP = 100;
|
||
const TITLE_FLASH_INTERVAL_MS = 1500;
|
||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000;
|
||
const AUTO_CLOSE_NOTIFICATION_MS = 8000;
|
||
const THROTTLE_DELAY_MS = 100;
|
||
const TERMINAL_CHUNK_SIZE = 128 * 1024;
|
||
const TERMINAL_TAIL_SIZE = 256 * 1024;
|
||
const SYNC_WAIT_TIMEOUT_MS = 50;
|
||
const STATS_POLLING_INTERVAL_MS = 2000;
|
||
|
||
// Z-index layers
|
||
const ZINDEX_SUBAGENT_BASE = 1000;
|
||
const ZINDEX_PLAN_SUBAGENT_BASE = 1100;
|
||
const ZINDEX_LOG_VIEWER_BASE = 2000;
|
||
const ZINDEX_IMAGE_POPUP_BASE = 3000;
|
||
|
||
// Floating window layout
|
||
const WINDOW_INITIAL_TOP_PX = 120;
|
||
const WINDOW_CASCADE_OFFSET_PX = 30;
|
||
const WINDOW_MIN_WIDTH_PX = 200;
|
||
const WINDOW_MIN_HEIGHT_PX = 200;
|
||
const WINDOW_DEFAULT_WIDTH_PX = 300;
|
||
|
||
// Scheduler API helper
|
||
const _hasScheduler = typeof globalThis.scheduler?.postTask === 'function';
|
||
function scheduleBackground(fn) { ... }
|
||
|
||
// DEC mode 2026 - Synchronized Output
|
||
const DEC_SYNC_START = '\x1b[?2026h';
|
||
const DEC_SYNC_END = '\x1b[?2026l';
|
||
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
||
|
||
// Built-in respawn presets
|
||
const BUILTIN_RESPAWN_PRESETS = [ ... ];
|
||
|
||
// DEC 2026 sync segment parser
|
||
function extractSyncSegments(data) { ... }
|
||
|
||
// HTML escape utility (shared by NotificationManager and CodemanApp)
|
||
const _htmlEscapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||
const _htmlEscapePattern = /[&<>"']/g;
|
||
function escapeHtml(text) {
|
||
if (typeof text !== 'string') return '';
|
||
return text.replace(_htmlEscapePattern, (ch) => _htmlEscapeMap[ch]);
|
||
}
|
||
|
||
// Unified event coordinate extraction (mouse + touch)
|
||
function getEventCoords(e) { ... }
|
||
```
|
||
|
||
### What Changes in app.js
|
||
|
||
1. **Delete lines 1-159** (constants section) — moved to `constants.js`
|
||
2. **Delete lines 273-285** (`getEventCoords`) — moved to `constants.js`
|
||
3. **Delete lines 2173-2215** (`extractSyncSegments`) — moved to `constants.js`
|
||
4. **Delete `escapeHtml` instance method** from CodemanApp class (lines 15162-15175, including static maps) — replaced by global `escapeHtml()` in `constants.js`
|
||
5. **Delete `escapeHtml` instance method** from NotificationManager class (line 2659-2662) — replaced by global
|
||
6. **Replace `this.escapeHtml(...)` calls** throughout CodemanApp (~70 occurrences) with `escapeHtml(...)` (global)
|
||
7. **Replace `this.escapeHtml(...)` calls** in NotificationManager (lines 2461-2467, 4 calls) with `escapeHtml(...)`
|
||
|
||
### What Changes in ralph-wizard.js
|
||
|
||
Replace 3 `this.escapeHtml(...)` calls (lines 364, 727, 850) with `escapeHtml(...)` (global function from `constants.js`, loaded before both `app.js` and `ralph-wizard.js`).
|
||
|
||
### What Changes in index.html
|
||
|
||
Add the new script tag BEFORE `app.js`:
|
||
|
||
```html
|
||
<script defer src="constants.js?v=VERSION"></script>
|
||
<script defer src="app.js?v=VERSION"></script>
|
||
<script defer src="ralph-wizard.js?v=VERSION"></script>
|
||
```
|
||
|
||
### Verification
|
||
|
||
1. All constant names are already globals — no reference changes needed in app.js for constants
|
||
2. `escapeHtml` usage changes from `this.escapeHtml()` → `escapeHtml()` (global) in CodemanApp, NotificationManager, and ralph-wizard.js
|
||
3. `extractSyncSegments` is already called as a global function — no change needed
|
||
4. Browser test: load page, verify terminal renders, tabs work, notifications fire
|
||
|
||
---
|
||
|
||
## Step 2: Extract Mobile Handlers
|
||
|
||
**New file**: `src/web/public/mobile-handlers.js`
|
||
**Lines moved from app.js**: 168-628 (`MobileDetection`, `KeyboardHandler`, `SwipeHandler`)
|
||
**Estimated size**: ~460 lines
|
||
**Time**: ~2 hours
|
||
|
||
### What Moves
|
||
|
||
Three related mobile objects that form a cohesive unit:
|
||
|
||
```javascript
|
||
// mobile-handlers.js — Device detection, keyboard handling, and swipe navigation
|
||
|
||
const MobileDetection = {
|
||
isTouchDevice() { ... },
|
||
isIOS() { ... },
|
||
isSafari() { ... },
|
||
isSmallScreen() { ... },
|
||
isMediumScreen() { ... },
|
||
getDeviceType() { ... },
|
||
updateBodyClass() { ... },
|
||
init() { ... },
|
||
cleanup() { ... }
|
||
};
|
||
|
||
const KeyboardHandler = {
|
||
lastViewportHeight: 0,
|
||
keyboardVisible: false,
|
||
initialViewportHeight: 0,
|
||
init() { ... },
|
||
cleanup() { ... },
|
||
handleViewportResize() { ... },
|
||
updateLayoutForKeyboard() { ... },
|
||
resetLayout() { ... },
|
||
onKeyboardShow() { ... },
|
||
onKeyboardHide() { ... },
|
||
_sendTerminalResize() { ... },
|
||
isInputElement(el) { ... },
|
||
scrollInputIntoView(input) { ... }
|
||
};
|
||
|
||
const SwipeHandler = {
|
||
startX: 0,
|
||
startY: 0,
|
||
startTime: 0,
|
||
minSwipeDistance: 80,
|
||
maxSwipeTime: 300,
|
||
maxVerticalDrift: 100,
|
||
init() { ... },
|
||
onTouchStart(e) { ... },
|
||
onTouchEnd(e) { ... }
|
||
};
|
||
```
|
||
|
||
### Cross-Reference Analysis
|
||
|
||
| Object | References to... | How to handle |
|
||
|--------|-----------------|---------------|
|
||
| `MobileDetection` | None (standalone) | Works as-is |
|
||
| `KeyboardHandler` | `MobileDetection` (direct calls) | Guaranteed loaded before (same file) |
|
||
| `KeyboardHandler` | `typeof app !== 'undefined'` (6 guards) | Preserved as-is — `app` defined later |
|
||
| `KeyboardHandler` | `typeof KeyboardAccessoryBar !== 'undefined'` (2 guards) | Preserved — `KeyboardAccessoryBar` defined in later file, guards handle deferral |
|
||
| `SwipeHandler` | `MobileDetection.isTouchDevice()` (1 call) | Same file, no issue |
|
||
| `SwipeHandler` | `typeof app !== 'undefined'` (2 guards) | Preserved as-is |
|
||
|
||
### What Changes in app.js
|
||
|
||
1. **Delete lines 160-628** (MobileDetection section header through end of SwipeHandler)
|
||
2. `getEventCoords()` (lines 273-285) already moved to `constants.js` in Step 1 — verify deleted
|
||
3. No reference changes needed — all three objects are already globals accessed by name
|
||
|
||
### What Changes in index.html
|
||
|
||
```html
|
||
<script defer src="constants.js?v=VERSION"></script>
|
||
<script defer src="mobile-handlers.js?v=VERSION"></script> <!-- NEW -->
|
||
<script defer src="app.js?v=VERSION"></script>
|
||
<script defer src="ralph-wizard.js?v=VERSION"></script>
|
||
```
|
||
|
||
### CodemanApp.init() Update
|
||
|
||
The `init()` method initializes mobile handlers. Verify this code path still works:
|
||
|
||
```javascript
|
||
// In app.js CodemanApp.init():
|
||
MobileDetection.init(); // Now global from mobile-handlers.js
|
||
KeyboardHandler.init(); // Now global from mobile-handlers.js
|
||
SwipeHandler.init(); // Now global from mobile-handlers.js
|
||
```
|
||
|
||
These are already called as globals, so no changes needed.
|
||
|
||
### Verification
|
||
|
||
1. Load page on desktop — verify MobileDetection sets correct body classes
|
||
2. Load page on mobile viewport (DevTools device mode, ≤768px) — verify:
|
||
- Touch detection works
|
||
- Keyboard show/hide repositions toolbar
|
||
- Swipe left/right switches sessions
|
||
3. Check that `window.MobileDetection` is still set (line 15196 of app.js). `MobileDetection` is now a global from `mobile-handlers.js` — the assignment still works.
|
||
|
||
---
|
||
|
||
## Step 3: Extract Voice Input
|
||
|
||
**New file**: `src/web/public/voice-input.js`
|
||
**Lines moved from app.js**: 631-1479 (`DeepgramProvider`, `VoiceInput`)
|
||
**Estimated size**: ~850 lines
|
||
**Time**: ~3 hours
|
||
|
||
### What Moves
|
||
|
||
```javascript
|
||
// voice-input.js — Multi-provider voice input (Deepgram Nova-3 + Web Speech API)
|
||
|
||
const DeepgramProvider = {
|
||
_ws: null,
|
||
_mediaRecorder: null,
|
||
_stream: null,
|
||
// ... 200+ lines
|
||
async start(opts) { ... },
|
||
stop() { ... },
|
||
cleanup() { ... }
|
||
};
|
||
|
||
const VoiceInput = {
|
||
recognition: null,
|
||
isRecording: false,
|
||
supported: false,
|
||
_activeProvider: null,
|
||
// ... 630+ lines
|
||
init() { ... },
|
||
toggle() { ... },
|
||
start() { ... },
|
||
stop() { ... },
|
||
_startDeepgram() { ... },
|
||
_startWebSpeech() { ... },
|
||
// Audio level meter, config persistence, UI updates
|
||
cleanup() { ... }
|
||
};
|
||
```
|
||
|
||
### Cross-Reference Analysis
|
||
|
||
| Object | References to... | How to handle |
|
||
|--------|-----------------|---------------|
|
||
| `DeepgramProvider` | None (standalone WebSocket) | Works as-is |
|
||
| `VoiceInput` | `DeepgramProvider` (direct calls) | Same file — guaranteed available |
|
||
| `VoiceInput` | `typeof app !== 'undefined'` (~15 guards) | Preserved — `app` defined later |
|
||
|
||
**Note**: VoiceInput does NOT reference `MobileDetection`. Its only external dependency is `app` (via `typeof` guards) and `DeepgramProvider` (same file).
|
||
|
||
### What Changes in app.js
|
||
|
||
1. **Delete lines 631-1479** (DeepgramProvider + VoiceInput sections)
|
||
2. No reference changes — `VoiceInput` and `DeepgramProvider` are accessed as globals throughout app.js
|
||
|
||
### What Changes in index.html
|
||
|
||
```html
|
||
<script defer src="constants.js?v=VERSION"></script>
|
||
<script defer src="mobile-handlers.js?v=VERSION"></script>
|
||
<script defer src="voice-input.js?v=VERSION"></script> <!-- NEW -->
|
||
<script defer src="app.js?v=VERSION"></script>
|
||
<script defer src="ralph-wizard.js?v=VERSION"></script>
|
||
```
|
||
|
||
### Important: VoiceInput ↔ CodemanApp References
|
||
|
||
VoiceInput accesses `app` through `typeof app !== 'undefined'` guards in these cases:
|
||
- `app.activeSessionId` — to know which session receives voice input
|
||
- `app.terminal` — to focus terminal after voice input
|
||
- `app._sendInputAsync()` — to send transcribed text
|
||
- `app.notificationManager` — to show voice-related notifications
|
||
- `app.sessions.get()` — to check session state
|
||
|
||
All of these use the existing guard pattern and will continue to work because `app` is initialized in `app.js` which loads after `voice-input.js`. The guards handle the case where `VoiceInput.init()` is called before `app` exists (which doesn't happen in practice, but is safe regardless).
|
||
|
||
### Verification
|
||
|
||
1. Load page — verify no console errors related to voice/Deepgram
|
||
2. If Deepgram API key is configured in settings, test voice toggle
|
||
3. Verify `typeof VoiceInput !== 'undefined'` checks in app.js still resolve correctly
|
||
|
||
---
|
||
|
||
## Step 4: Extract NotificationManager
|
||
|
||
**New file**: `src/web/public/notification-manager.js`
|
||
**Lines moved from app.js**: 2218-2663 (`NotificationManager` class)
|
||
**Estimated size**: ~450 lines
|
||
**Time**: ~2 hours
|
||
|
||
### What Moves
|
||
|
||
```javascript
|
||
// notification-manager.js — Multi-layer notification system
|
||
// 5 layers: in-app drawer, tab flash, browser Notification API, Web Push, audio beep
|
||
|
||
class NotificationManager {
|
||
constructor(app) {
|
||
this.app = app;
|
||
this.notifications = [];
|
||
this.unreadCount = 0;
|
||
this.isTabVisible = !document.hidden;
|
||
this.isDrawerOpen = false;
|
||
this.originalTitle = document.title;
|
||
this.preferences = this.loadPreferences();
|
||
// Visibility change listeners...
|
||
}
|
||
|
||
loadPreferences() { ... } // localStorage with v1→v4 migration
|
||
savePreferences() { ... }
|
||
notify({ urgency, category, sessionId, title, message }) { ... }
|
||
_addNotification() { ... }
|
||
_broadcastNotification() { ... }
|
||
_flashTab() { ... }
|
||
_showBrowserNotification() { ... }
|
||
_playAudio() { ... }
|
||
_sendPushNotification() { ... }
|
||
onTabVisible() { ... }
|
||
markAllRead() { ... }
|
||
clearAll() { ... }
|
||
removeNotification(id) { ... }
|
||
getUnreadCount() { ... }
|
||
relativeTime(ts) { ... }
|
||
cleanup() { ... }
|
||
// Note: escapeHtml() already removed in Step 1 — uses global escapeHtml()
|
||
}
|
||
```
|
||
|
||
### Cross-Reference Analysis
|
||
|
||
| Reference | Direction | How to handle |
|
||
|-----------|-----------|---------------|
|
||
| `NotificationManager` → `this.app` | Constructor injection | Already uses `this.app` — works unchanged |
|
||
| `NotificationManager` → `MobileDetection` | Direct calls (2 places: lines 2269, 2324) | `mobile-handlers.js` loaded first |
|
||
| `CodemanApp` → `NotificationManager` | `this.notificationManager = new NotificationManager(this)` | Constructor creates instance — needs class available |
|
||
| `NotificationManager` → `escapeHtml()` | Global function (4 calls) | Already migrated in Step 1 to `constants.js` |
|
||
|
||
**Note**: NotificationManager does NOT reference VoiceInput.
|
||
|
||
### What Changes in app.js
|
||
|
||
1. **Delete lines 2218-2663** (NotificationManager class, including the `relativeTime` and old `escapeHtml` utilities at lines 2650-2662)
|
||
2. The CodemanApp constructor already creates the instance:
|
||
```javascript
|
||
this.notificationManager = new NotificationManager(this);
|
||
```
|
||
This works because `notification-manager.js` loads before `app.js`.
|
||
|
||
### What Changes in index.html
|
||
|
||
```html
|
||
<script defer src="constants.js?v=VERSION"></script>
|
||
<script defer src="mobile-handlers.js?v=VERSION"></script>
|
||
<script defer src="voice-input.js?v=VERSION"></script>
|
||
<script defer src="notification-manager.js?v=VERSION"></script> <!-- NEW -->
|
||
<script defer src="app.js?v=VERSION"></script>
|
||
<script defer src="ralph-wizard.js?v=VERSION"></script>
|
||
```
|
||
|
||
### Verification
|
||
|
||
1. Load page — verify notification badge appears in header
|
||
2. Trigger a notification event (e.g., create a session, send input) — verify:
|
||
- In-app notification appears in drawer
|
||
- Tab title flashes when not focused
|
||
- Browser notification appears if enabled
|
||
3. Open Settings → Notifications — verify preferences load/save correctly
|
||
|
||
---
|
||
|
||
## Step 5: Extract FocusTrap & KeyboardAccessoryBar
|
||
|
||
**New file**: `src/web/public/keyboard-accessory.js`
|
||
**Lines moved from app.js**: 1480-1748 (`KeyboardAccessoryBar`, `FocusTrap`)
|
||
**Estimated size**: ~270 lines
|
||
**Time**: ~1 hour
|
||
|
||
### Why Combine These Two?
|
||
|
||
`FocusTrap` (60 lines) is too small for its own file but doesn't naturally belong in any other module. `KeyboardAccessoryBar` (200 lines) is mobile-focused but depends on `MobileDetection` (which is in `mobile-handlers.js`). Combining them creates a cohesive "keyboard interaction" module at a reasonable size.
|
||
|
||
### What Moves
|
||
|
||
```javascript
|
||
// keyboard-accessory.js — Mobile keyboard accessory bar + modal focus trapping
|
||
|
||
class FocusTrap {
|
||
constructor(element) { ... }
|
||
activate() { ... }
|
||
deactivate() { ... }
|
||
getFocusableElements() { ... }
|
||
handleKeydown(e) { ... }
|
||
}
|
||
|
||
const KeyboardAccessoryBar = {
|
||
element: null,
|
||
_confirmTimer: null,
|
||
_confirmAction: null,
|
||
init() { ... }, // Creates DOM, mobile-only
|
||
handleAction(action, btn) { ... },
|
||
setConfirm(action, btn) { ... },
|
||
clearConfirm() { ... },
|
||
sendCommand(command) { ... },
|
||
sendKey(escapeSequence) { ... },
|
||
pasteFromClipboard() { ... },
|
||
show() { ... },
|
||
hide() { ... }
|
||
};
|
||
```
|
||
|
||
### Cross-Reference Analysis
|
||
|
||
| Reference | Direction | How to handle |
|
||
|-----------|-----------|---------------|
|
||
| `FocusTrap` | Standalone | No dependencies |
|
||
| `KeyboardAccessoryBar` → `MobileDetection` | Direct call in `init()` | `mobile-handlers.js` loaded first |
|
||
| `KeyboardAccessoryBar` → `typeof app` | 8+ guards for sending input | `app` defined later |
|
||
| `CodemanApp` → `FocusTrap` | `this.activeFocusTrap = new FocusTrap(el)` | Needs class available |
|
||
| `CodemanApp` → `KeyboardAccessoryBar` | `KeyboardAccessoryBar.init()` in init() | Needs object available |
|
||
| `KeyboardHandler` → `typeof KeyboardAccessoryBar` | 2 guards for show/hide | `keyboard-accessory.js` loaded after `mobile-handlers.js` — **LOAD ORDER MATTERS** |
|
||
| `ralph-wizard.js` → `FocusTrap` | `new FocusTrap(modal)` (direct usage) | `keyboard-accessory.js` loaded before `ralph-wizard.js` |
|
||
|
||
### Critical: Load Order for KeyboardHandler ↔ KeyboardAccessoryBar
|
||
|
||
`KeyboardHandler` (in `mobile-handlers.js`) references `typeof KeyboardAccessoryBar !== 'undefined'`. For this to resolve as `true`, `keyboard-accessory.js` must load BEFORE `mobile-handlers.js`... but `KeyboardAccessoryBar` depends on `MobileDetection` (in `mobile-handlers.js`).
|
||
|
||
**This is a circular dependency!**
|
||
|
||
**Resolution**: The `typeof` guard in `KeyboardHandler` already handles the case where `KeyboardAccessoryBar` doesn't exist yet. The guard runs at **runtime** (when `onKeyboardShow/Hide()` fires), not at **load time**. By the time the keyboard actually shows/hides (user interaction), all scripts have loaded. So the current load order works:
|
||
|
||
```
|
||
mobile-handlers.js → defines MobileDetection, KeyboardHandler (with typeof guards)
|
||
keyboard-accessory.js → defines KeyboardAccessoryBar (MobileDetection available)
|
||
app.js → calls KeyboardHandler.init(), KeyboardAccessoryBar.init()
|
||
```
|
||
|
||
When `KeyboardHandler.onKeyboardShow()` fires at runtime, `KeyboardAccessoryBar` is already defined.
|
||
|
||
### What Changes in app.js
|
||
|
||
1. **Delete lines 1480-1748** (KeyboardAccessoryBar + FocusTrap)
|
||
|
||
### What Changes in index.html
|
||
|
||
```html
|
||
<script defer src="constants.js?v=VERSION"></script>
|
||
<script defer src="mobile-handlers.js?v=VERSION"></script>
|
||
<script defer src="voice-input.js?v=VERSION"></script>
|
||
<script defer src="notification-manager.js?v=VERSION"></script>
|
||
<script defer src="keyboard-accessory.js?v=VERSION"></script> <!-- NEW -->
|
||
<script defer src="app.js?v=VERSION"></script>
|
||
<script defer src="ralph-wizard.js?v=VERSION"></script>
|
||
```
|
||
|
||
### Verification
|
||
|
||
1. Load page on mobile viewport — verify accessory bar appears when keyboard opens
|
||
2. Test arrow keys, /init, /clear, /compact, paste buttons in accessory bar
|
||
3. Open a modal (e.g., Settings) — verify Tab key cycles within modal (FocusTrap)
|
||
4. Open Ralph Wizard — verify focus trap works there too
|
||
|
||
---
|
||
|
||
## Step 6: Replace Inlined xterm-zerolag-input
|
||
|
||
**Lines removed from app.js**: 1756-2153 (~398 lines of inlined overlay code)
|
||
**New script tag**: Load built file from `packages/xterm-zerolag-input/`
|
||
**Time**: ~1.5 hours
|
||
|
||
### Current Problem
|
||
|
||
`app.js` contains a full copy of the `xterm-zerolag-input` package (7 helper functions + `ZerolagInputAddon` class + `LocalEchoOverlay` subclass). This code is also maintained in `packages/xterm-zerolag-input/src/`. Changes must be applied to both copies.
|
||
|
||
### Strategy
|
||
|
||
Build the package and serve its output as a separate script, then remove the inlined copy from `app.js`.
|
||
|
||
### Steps
|
||
|
||
1. **Build the package**:
|
||
```bash
|
||
cd packages/xterm-zerolag-input
|
||
npm run build
|
||
```
|
||
This produces `dist/` output via tsup.
|
||
|
||
2. **Check build output format**: The package's `tsup.config.ts` builds for ESM/CJS. We need an IIFE or global build that exposes `LocalEchoOverlay` as a global. Options:
|
||
- **Option A**: Add an IIFE build target to tsup config
|
||
- **Option B**: Create a small wrapper script that imports from the built package
|
||
- **Option C (simplest)**: Keep a thin wrapper in app.js that references the package's globals
|
||
|
||
**Recommended: Option A** — Add IIFE build to `packages/xterm-zerolag-input/tsup.config.ts`:
|
||
|
||
```typescript
|
||
export default defineConfig({
|
||
// ... existing config
|
||
format: ['esm', 'cjs', 'iife'],
|
||
globalName: 'XtermZerolagInput',
|
||
});
|
||
```
|
||
|
||
Then in the built IIFE file, `LocalEchoOverlay` would be available as `XtermZerolagInput.LocalEchoOverlay`.
|
||
|
||
3. **Copy built IIFE to vendor/**:
|
||
```bash
|
||
cp packages/xterm-zerolag-input/dist/index.global.js src/web/public/vendor/xterm-zerolag-input.js
|
||
```
|
||
|
||
4. **Add to index.html** (after xterm.js, before app.js):
|
||
```html
|
||
<script defer src="vendor/xterm-zerolag-input.js?v=VERSION"></script>
|
||
```
|
||
|
||
5. **Update app.js**: Delete lines 1756-2153 (inlined copy). In CodemanApp where `LocalEchoOverlay` is instantiated, update the reference:
|
||
```javascript
|
||
// Old:
|
||
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
||
// New (if IIFE exposes as global):
|
||
this._localEchoOverlay = new (XtermZerolagInput?.LocalEchoOverlay || LocalEchoOverlay)(this.terminal);
|
||
```
|
||
Or simpler: ensure the IIFE wrapper also exposes `LocalEchoOverlay` as a direct global:
|
||
```javascript
|
||
// At end of IIFE wrapper:
|
||
window.LocalEchoOverlay = XtermZerolagInput.LocalEchoOverlay;
|
||
window.ZerolagInputAddon = XtermZerolagInput.ZerolagInputAddon;
|
||
```
|
||
|
||
### Important: Build Pipeline Integration
|
||
|
||
After this step, the workflow for overlay changes becomes:
|
||
1. Edit source in `packages/xterm-zerolag-input/src/`
|
||
2. Run `npm run build` in the package directory
|
||
3. Copy built output to `src/web/public/vendor/`
|
||
4. Bump version query string in `index.html`
|
||
|
||
Consider adding a `scripts/build-overlay.sh` script to automate steps 2-3.
|
||
|
||
### Alternative: Simpler Approach (Skip Build)
|
||
|
||
If the IIFE build adds too much complexity, a simpler alternative is to keep the inlined code but add a comment marking it as generated:
|
||
|
||
```javascript
|
||
// ============================================================================
|
||
// xterm-zerolag-input — AUTO-GENERATED from packages/xterm-zerolag-input/
|
||
// Do NOT edit directly. Modify packages/xterm-zerolag-input/src/ and copy.
|
||
// ============================================================================
|
||
```
|
||
|
||
This documents the duplication without adding build complexity. The actual code reduction (398 lines) is the same either way since the copy in `app.js` is removed.
|
||
|
||
**Recommendation**: Go with the IIFE build approach. The 398-line reduction is worth the small build step addition.
|
||
|
||
### Verification
|
||
|
||
1. Load page — verify terminal renders
|
||
2. Type text — verify local echo overlay shows instant keystroke feedback
|
||
3. Type on mobile — verify overlay appears above keyboard
|
||
4. Verify no ghost artifacts when Claude is processing (overlay should hide)
|
||
|
||
---
|
||
|
||
## Step 7: Create ApiClient Wrapper
|
||
|
||
**New file**: `src/web/public/api-client.js`
|
||
**Pattern**: `Object.assign(CodemanApp.prototype, { ... })`
|
||
**Estimated size**: ~150 lines
|
||
**Time**: ~2 hours
|
||
|
||
### Problem
|
||
|
||
~98 `fetch()` calls scattered through CodemanApp with identical boilerplate:
|
||
|
||
```javascript
|
||
// Repeated pattern:
|
||
fetch(`/api/sessions/${sessionId}/something`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ key: value })
|
||
}).catch(() => {});
|
||
```
|
||
|
||
### What Moves
|
||
|
||
Extract a centralized API helper and replace repetitive patterns. This doesn't move existing methods — it adds new helper methods and gradually migrates call sites.
|
||
|
||
```javascript
|
||
// api-client.js — Centralized API fetch helper for CodemanApp
|
||
|
||
Object.assign(CodemanApp.prototype, {
|
||
/**
|
||
* Send a JSON API request. Handles Content-Type, JSON serialization, and error swallowing.
|
||
* @param {string} path - API path (e.g., '/api/sessions/123/input')
|
||
* @param {object} [opts] - { method, body, signal }
|
||
* @returns {Promise<Response|null>} Response or null on error
|
||
*/
|
||
async _api(path, opts = {}) {
|
||
const { method = 'GET', body, signal } = opts;
|
||
const fetchOpts = { method, signal };
|
||
if (body !== undefined) {
|
||
fetchOpts.headers = { 'Content-Type': 'application/json' };
|
||
fetchOpts.body = JSON.stringify(body);
|
||
}
|
||
try {
|
||
const res = await fetch(path, fetchOpts);
|
||
return res;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Send a JSON API request and parse the response as JSON.
|
||
* @param {string} path - API path
|
||
* @param {object} [opts] - { method, body, signal }
|
||
* @returns {Promise<any|null>} Parsed JSON or null on error
|
||
*/
|
||
async _apiJson(path, opts = {}) {
|
||
const res = await this._api(path, opts);
|
||
if (!res || !res.ok) return null;
|
||
try {
|
||
return await res.json();
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* POST JSON to an API endpoint (most common pattern).
|
||
* @param {string} path - API path
|
||
* @param {object} body - JSON body
|
||
* @returns {Promise<Response|null>}
|
||
*/
|
||
async _apiPost(path, body) {
|
||
return this._api(path, { method: 'POST', body });
|
||
},
|
||
|
||
/**
|
||
* DELETE an API resource.
|
||
* @param {string} path - API path
|
||
* @returns {Promise<Response|null>}
|
||
*/
|
||
async _apiDelete(path) {
|
||
return this._api(path, { method: 'DELETE' });
|
||
},
|
||
});
|
||
```
|
||
|
||
### Migration Strategy
|
||
|
||
Do NOT replace all 98 fetch calls in this step. Instead:
|
||
|
||
1. Add the `_api`/`_apiJson`/`_apiPost`/`_apiDelete` helpers via `Object.assign`
|
||
2. Migrate **10 of the most repeated patterns** as proof of concept:
|
||
- Session input: `_sendInputAsync()`
|
||
- Session resize: in `KeyboardHandler._sendTerminalResize()` and `setupEventListeners()`
|
||
- Session create: `quickStartSession()`
|
||
- Session delete: `deleteSession()`
|
||
- Respawn start/stop: `startRespawn()` / `stopRespawn()`
|
||
- Settings save: `saveSettings()`
|
||
- Ralph state: `sendRalphInput()`
|
||
- Subagent kill: `killSubagent()`
|
||
- File browse: `browseFiles()`
|
||
- Hook response: `respondToHook()`
|
||
3. Leave remaining fetch calls for incremental migration in future PRs
|
||
|
||
### What Changes in index.html
|
||
|
||
```html
|
||
<script defer src="app.js?v=VERSION"></script>
|
||
<script defer src="ralph-wizard.js?v=VERSION"></script>
|
||
<script defer src="api-client.js?v=VERSION"></script> <!-- NEW — after app.js -->
|
||
```
|
||
|
||
### Why After app.js?
|
||
|
||
`api-client.js` uses `Object.assign(CodemanApp.prototype, ...)`, which requires `CodemanApp` to be defined. Since `app.js` defines the class and immediately instantiates `const app = new CodemanApp()`, the prototype methods will be available on the `app` instance because JavaScript prototype lookup is dynamic.
|
||
|
||
**However**: Methods added via `Object.assign` AFTER instantiation are still available because JS reads the prototype at call time, not at creation time. So this works:
|
||
|
||
```javascript
|
||
// app.js:
|
||
class CodemanApp { ... }
|
||
const app = new CodemanApp();
|
||
// api-client.js (loaded after):
|
||
Object.assign(CodemanApp.prototype, { _api() { ... } });
|
||
// Later:
|
||
app._api('/api/status'); // ✓ Works — looks up prototype chain at call time
|
||
```
|
||
|
||
### Verification
|
||
|
||
1. Load page — verify no console errors
|
||
2. Create a session — verify `quickStartSession()` works
|
||
3. Send input — verify text is delivered
|
||
4. Delete a session — verify it's removed
|
||
5. Open/close subagent windows — verify kill works
|
||
|
||
---
|
||
|
||
## Step 8: Extract Subagent Window Manager
|
||
|
||
**New file**: `src/web/public/subagent-windows.js`
|
||
**Pattern**: `Object.assign(CodemanApp.prototype, { ... })`
|
||
**Estimated size**: ~700 lines
|
||
**Time**: ~3 hours
|
||
|
||
### Problem
|
||
|
||
Subagent window management is the largest self-contained system within CodemanApp. It handles:
|
||
- Floating terminal windows with drag/resize
|
||
- Parent-child connection lines (canvas/SVG)
|
||
- Minimized state + badge indicators
|
||
- Window Z-ordering and cascading layout
|
||
- Mobile-specific stacking
|
||
|
||
### What Moves
|
||
|
||
Extract all subagent window methods from CodemanApp. The actual method names and their locations in app.js:
|
||
|
||
```javascript
|
||
// subagent-windows.js — Floating subagent terminal window management
|
||
|
||
Object.assign(CodemanApp.prototype, {
|
||
// Tab badge (minimized agents indicator)
|
||
renderSubagentTabBadge(sessionId, minimizedAgents) { ... }, // line 5642
|
||
restoreMinimizedSubagent(agentId, sessionId) { ... }, // line 5800
|
||
permanentlyCloseMinimizedSubagent(agentId, sessionId) { ... }, // line 5908
|
||
|
||
// State persistence
|
||
async saveSubagentWindowStates() { ... }, // line 9760
|
||
async restoreSubagentWindowStates() { ... }, // line 9834
|
||
|
||
// Connection lines (parent ↔ child visual links)
|
||
updateConnectionLines() { ... }, // line 11998
|
||
_updateConnectionLinesImmediate() { ... }, // line 12010
|
||
|
||
// Window lifecycle
|
||
openSubagentWindow(agentId) { ... }, // line 12274 (~280 LOC)
|
||
closeSubagentWindow(agentId) { ... }, // line 12551 (~40 LOC)
|
||
relayoutMobileSubagentWindows() { ... }, // line 12590 (~90 LOC)
|
||
cleanupAllFloatingWindows() { ... }, // line 12680 (~170 LOC)
|
||
|
||
// Shared drag utility (used by subagent + teammate + log viewer windows)
|
||
makeWindowDraggable(win, handle) { ... }, // line 12849 (~50 LOC)
|
||
});
|
||
```
|
||
|
||
### What Stays in app.js
|
||
|
||
- The `subagentWindows`, `minimizedSubagents`, `subagentWindowZIndex` **properties** stay in the CodemanApp constructor (they are state, not methods)
|
||
- SSE event handlers for `subagent:discovered`, `subagent:updated`, `subagent:completed` stay in `handleSSEEvent()` — they call into the extracted methods
|
||
- The subagent panel rendering stays (it's part of the main UI, not the floating windows)
|
||
|
||
### Important: Non-contiguous Methods
|
||
|
||
Unlike Steps 1-6 (which moved contiguous blocks), the subagent window methods are scattered across CodemanApp:
|
||
- Tab badge methods: lines ~5642-5926
|
||
- State persistence: lines ~9760-9906
|
||
- Connection lines + window lifecycle: lines ~11998-12900
|
||
|
||
The extraction requires cutting from **3 separate regions** of app.js. Be careful to:
|
||
1. Cut each method completely (including any JSDoc comments above it)
|
||
2. Leave surrounding methods intact
|
||
3. Verify no shared local variables between cut and remaining methods
|
||
|
||
### `makeWindowDraggable` Dependency
|
||
|
||
`makeWindowDraggable()` is used by subagent windows, teammate terminal windows (line 13298), and log viewer windows. If it moves to `subagent-windows.js`, it's still accessible via prototype — teammate/log-viewer code in `app.js` can call `this.makeWindowDraggable()` without issue.
|
||
|
||
### What Changes in index.html
|
||
|
||
```html
|
||
<script defer src="app.js?v=VERSION"></script>
|
||
<script defer src="ralph-wizard.js?v=VERSION"></script>
|
||
<script defer src="api-client.js?v=VERSION"></script>
|
||
<script defer src="subagent-windows.js?v=VERSION"></script> <!-- NEW -->
|
||
```
|
||
|
||
### Verification
|
||
|
||
1. Start a session that spawns subagents — verify floating windows appear
|
||
2. Drag a window — verify smooth movement
|
||
3. Resize a window — verify constraints (min width/height)
|
||
4. Minimize a window — verify badge appears on session tab
|
||
5. Click badge — verify dropdown, click to restore
|
||
6. Switch sessions — verify windows for other session hide
|
||
7. Mobile viewport — verify windows stack from bottom when keyboard visible
|
||
8. Open a teammate terminal window — verify `makeWindowDraggable` still works from app.js
|
||
|
||
---
|
||
|
||
## Execution Order & Dependencies
|
||
|
||
Execute steps in this order. Each step is independently deployable and verifiable.
|
||
|
||
```
|
||
Step 1: constants.js (self-contained, no deps)
|
||
↓
|
||
Step 2: mobile-handlers.js (depends on constants.js for getEventCoords)
|
||
↓
|
||
Step 3: voice-input.js (standalone; only needs `app` at runtime via typeof guard)
|
||
↓
|
||
Step 4: notification-manager.js (depends on mobile-handlers.js for MobileDetection)
|
||
↓
|
||
Step 5: keyboard-accessory.js (depends on mobile-handlers.js for MobileDetection)
|
||
↓
|
||
Step 6: xterm-zerolag-input (depends on vendor/xterm.js)
|
||
↓
|
||
Step 7: api-client.js (depends on CodemanApp — loads after app.js)
|
||
↓
|
||
Step 8: subagent-windows.js (depends on CodemanApp — loads after app.js)
|
||
```
|
||
|
||
**Parallelization opportunities**:
|
||
- Steps 3 + 5 can run in parallel (no cross-dependencies)
|
||
- Steps 4 + 5 can run in parallel (both depend on mobile-handlers but not each other)
|
||
- Steps 7 + 8 can run in parallel (both extend CodemanApp prototype independently)
|
||
|
||
### index.html Final Script Order
|
||
|
||
```html
|
||
<!-- Vendor: xterm.js + addons -->
|
||
<script defer src="vendor/xterm.min.js"></script>
|
||
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
||
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
|
||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||
<script defer src="vendor/xterm-zerolag-input.js"></script> <!-- Step 6 -->
|
||
|
||
<!-- Codeman: utilities & standalone modules (no CodemanApp dependency) -->
|
||
<script defer src="constants.js"></script> <!-- Step 1 -->
|
||
<script defer src="mobile-handlers.js"></script> <!-- Step 2 -->
|
||
<script defer src="voice-input.js"></script> <!-- Step 3 -->
|
||
<script defer src="notification-manager.js"></script> <!-- Step 4 -->
|
||
<script defer src="keyboard-accessory.js"></script> <!-- Step 5 -->
|
||
|
||
<!-- Codeman: core application -->
|
||
<script defer src="app.js"></script> <!-- REDUCED -->
|
||
|
||
<!-- Codeman: prototype extensions (loaded after CodemanApp defined) -->
|
||
<script defer src="ralph-wizard.js"></script> <!-- Existing -->
|
||
<script defer src="api-client.js"></script> <!-- Step 7 -->
|
||
<script defer src="subagent-windows.js"></script> <!-- Step 8 -->
|
||
```
|
||
|
||
---
|
||
|
||
## Validation Checklist
|
||
|
||
After EACH step, verify:
|
||
|
||
- [ ] Page loads without console errors
|
||
- [ ] All `<script defer>` tags reference correct files with updated `?v=` versions
|
||
- [ ] Terminals render and accept input
|
||
- [ ] Session tabs work (create, switch, delete, drag reorder)
|
||
- [ ] Notifications fire (create session → notification in drawer)
|
||
- [ ] Subagent windows appear when Claude spawns agents
|
||
- [ ] Mobile viewport works (resize to ≤768px, verify keyboard, swipe, accessory bar)
|
||
- [ ] `npm run lint` passes (app.js is excluded from lint, but check for syntax errors)
|
||
- [ ] `npm run format:check` passes
|
||
- [ ] Dev server restarts cleanly: `npx tsx src/index.ts web`
|
||
|
||
### Additional checks after all steps complete:
|
||
|
||
- [ ] Local echo overlay shows instant keystrokes
|
||
- [ ] Ralph wizard opens and functions
|
||
- [ ] Voice input toggle works (if configured)
|
||
- [ ] Settings modal opens with focus trap
|
||
- [ ] Respawn UI (timer banner, countdown) functions
|
||
- [ ] Run `du -b src/web/public/app.js` — target: ≤10,000 lines (down from 15,196)
|
||
|
||
### Size Targets
|
||
|
||
| File | Before | After |
|
||
|------|--------|-------|
|
||
| `app.js` | 15,196 lines | ~11,500 lines |
|
||
| `constants.js` | — | ~230 lines |
|
||
| `mobile-handlers.js` | — | ~460 lines |
|
||
| `voice-input.js` | — | ~850 lines |
|
||
| `notification-manager.js` | — | ~450 lines |
|
||
| `keyboard-accessory.js` | — | ~270 lines |
|
||
| `api-client.js` | — | ~150 lines |
|
||
| `subagent-windows.js` | — | ~700 lines |
|
||
| `ralph-wizard.js` (existing) | 1,037 lines | 1,037 lines (unchanged) |
|
||
| **Total new files** | — | **7 files** |
|
||
| **Net LOC change** | — | ~0 (refactor only) |
|
||
| **app.js reduction** | — | **~3,700 lines removed** (~24%) |
|
||
|
||
---
|
||
|
||
## Risk Assessment
|
||
|
||
| Risk | Likelihood | Impact | Mitigation |
|
||
|------|-----------|--------|------------|
|
||
| Script load order breaks a cross-reference | Medium | HIGH | All existing `typeof X !== 'undefined'` guards handle missing globals; verify with manual browser test after each step |
|
||
| `Object.assign` prototype methods unavailable at call time | Low | HIGH | JS prototype lookup is dynamic — works even when methods added after instantiation. Test key flows (input, delete, window open) |
|
||
| Missing constant in extracted file | Low | MEDIUM | TypeScript won't help (vanilla JS). Search for all constant names before extraction with `grep -n 'CONSTANT_NAME' app.js` |
|
||
| Cache busting failure (users see stale JS) | Medium | MEDIUM | Always update `?v=` version string. Note: production caches static files for 1 hour (`maxAge: '1h'`). Users may need Ctrl+Shift+R |
|
||
| Mobile regression (keyboard, swipe, overlay) | Medium | HIGH | Test on actual mobile device or DevTools device mode after Steps 2, 3, 5 |
|
||
| Performance regression from additional HTTP requests | Low | LOW | 7 new script files × ~5KB each = ~35KB total. With HTTP/2 multiplexing and browser caching, negligible impact. All files are `defer` (non-blocking) |
|
||
| `ralph-wizard.js` breaks after `escapeHtml` change | Low | MEDIUM | Step 1 migrates `this.escapeHtml()` → `escapeHtml()` in ralph-wizard.js. Verify all 3 call sites (lines 364, 727, 850) |
|
||
| Non-contiguous extraction in Step 8 | Medium | HIGH | Subagent methods span 3 regions of app.js. Use grep to verify all references before and after extraction |
|
||
|
||
### What NOT to Touch in This Phase
|
||
|
||
- **CodemanApp constructor** — Do not split the 42+ properties into sub-objects. That's a Phase 6+ effort requiring careful state management.
|
||
- **SSE event handling** — The `handleSSEEvent()` method is the spine of the app. Keep it in app.js.
|
||
- **Terminal rendering pipeline** — `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()` are performance-critical and deeply coupled to xterm.js state. Keep in app.js.
|
||
- **`selectSession()`** — 266-line method with complex state transitions. Keep in app.js for now.
|
||
- **`renderAppSettings()`** — Settings UI is deeply coupled to server-backed settings. Keep in app.js.
|
||
- **Session lifecycle methods** — `quickStartSession()`, `deleteSession()`, `stopSession()` — these coordinate too many subsystems to extract cleanly.
|
||
|
||
### Patterns to Preserve
|
||
|
||
These working patterns should NOT be changed:
|
||
- `typeof X !== 'undefined'` guards for optional globals
|
||
- `Object.assign(CodemanApp.prototype, ...)` for method injection
|
||
- `<script defer>` for load ordering
|
||
- `window.app = app` for debugging access
|
||
- `app._elemCache` for DOM element caching
|
||
- All `requestAnimationFrame`-based batching in terminal writes
|
||
|
||
---
|
||
|
||
## Future Phases (Not in Scope)
|
||
|
||
For reference, these are natural follow-up phases after Phase 5:
|
||
|
||
**Phase 6 — Config Consolidation** (from findings roadmap):
|
||
- Create `src/config/server-config.ts`, `src/config/timing-config.ts`
|
||
- Move 40+ scattered backend constants
|
||
|
||
**Phase 7 — Test Infrastructure** (from findings roadmap):
|
||
- Consolidate MockSession into `test/mocks/`
|
||
- Add server.ts route tests
|
||
|
||
**Phase 8 — CodemanApp Class Decomposition** (future, requires Phase 5):
|
||
- Extract `SessionUI` class (session lifecycle, tab management)
|
||
- Extract `TerminalRenderer` class (batching, sync, flicker filter)
|
||
- Extract `SubagentTracker` class (agent discovery, tool call tracking)
|
||
- Extract `RalphStateUI` class (Ralph panel, state rendering)
|
||
- Extract `SSEConnector` class (EventSource, reconnect, backoff)
|
||
- Reduce CodemanApp to orchestrator (~2000 lines)
|
||
|
||
**Phase 9 — ES Module Migration** (future, if bundler adopted):
|
||
- Convert all `.js` files to ES modules
|
||
- Replace globals with import/export
|
||
- Add build step with Vite or esbuild
|
||
- Enable tree-shaking
|