# 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
```
### 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
```
### 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
```
### 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
```
### 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
```
### 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
```
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 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} 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}
*/
async _apiPost(path, body) {
return this._api(path, { method: 'POST', body });
},
/**
* DELETE an API resource.
* @param {string} path - API path
* @returns {Promise}
*/
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
```
### 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
```
### 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
```
---
## Validation Checklist
After EACH step, verify:
- [ ] Page loads without console errors
- [ ] All `