diff --git a/CLAUDE.md b/CLAUDE.md
index 76c6483a..ad648b75 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -226,8 +226,15 @@ Re-exported via `src/utils/index.ts`. Key exports:
| File | Purpose |
|------|---------|
| `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading |
-| `src/web/public/app.js` | Core UI: xterm.js, tab management, subagent windows, mobile support (~15K lines) |
-| `src/web/public/ralph-wizard.js` | Ralph Loop wizard UI extracted from app.js (~1K lines) |
+| `src/web/public/constants.js` | Shared constants, timing values, Z-index layers, Web Push utilities |
+| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`) |
+| `src/web/public/mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` objects |
+| `src/web/public/voice-input.js` | `DeepgramProvider`, `VoiceInput` objects for speech-to-text |
+| `src/web/public/notification-manager.js` | `NotificationManager` class (5-layer notification system) |
+| `src/web/public/keyboard-accessory.js` | `KeyboardAccessoryBar` and `FocusTrap` classes |
+| `src/web/public/subagent-windows.js` | Subagent window management (open, close, drag, connection lines) |
+| `src/web/public/app.js` | Core UI: xterm.js, tab management, settings |
+| `src/web/public/ralph-wizard.js` | Ralph Loop wizard UI |
| `src/web/public/styles.css` | Main styling (dark theme, layout, components) |
| `src/web/public/mobile.css` | Responsive overrides for screens <1024px (loaded conditionally via `media` attribute) |
| `src/web/public/upload.html` | Screenshot upload page served at `/upload.html` |
@@ -235,20 +242,23 @@ Re-exported via `src/utils/index.ts`. Key exports:
| `src/web/public/manifest.json` | Minimal PWA manifest (required for push on Android) |
| `src/web/public/vendor/` | Self-hosted xterm.js + addons (eliminates CDN latency) |
-### Frontend Architecture (`app.js`)
+**Script loading order** (index.html): `constants.js` → `mobile-handlers.js` → `voice-input.js` → `notification-manager.js` → `keyboard-accessory.js` → `app.js` → `ralph-wizard.js` → `api-client.js` → `subagent-windows.js`. All modules share global scope — order matters for dependencies.
-The frontend is a single ~15K-line vanilla JS file with these key systems:
+### Frontend Architecture
-| System | Key Classes/Functions | Purpose |
-|--------|----------------------|---------|
-| **Terminal rendering** | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()` | 60fps batched writes with DEC 2026 sync |
-| **Local echo overlay** | `LocalEchoOverlay` class | DOM overlay for instant mobile keystroke feedback |
-| **Mobile support** | `MobileDetection`, `KeyboardHandler`, `SwipeHandler`, `KeyboardAccessoryBar` | Touch input, viewport adaptation, swipe navigation |
-| **Subagent windows** | `openSubagentWindow()`, `closeSubagentWindow()`, `updateConnectionLines()` | Floating terminal windows with parent connection lines |
-| **Notifications** | `NotificationManager` class | 5-layer: in-app drawer, tab flash, browser API, web push, audio beep |
-| **SSE connection** | `connectSSE()`, `addListener()` | EventSource with exponential backoff (1-30s), offline queue (64KB) |
-| **Settings** | `openAppSettings()`, `apply*Visibility()` | Server-backed + localStorage persistence |
-| **Focus management** | `FocusTrap` class | Modal keyboard navigation with focus restore |
+The frontend is split across multiple vanilla JS modules (extracted from the original monolithic `app.js`). Key systems:
+
+| System | Module | Key Classes/Functions | Purpose |
+|--------|--------|----------------------|---------|
+| **Terminal rendering** | `app.js` | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()` | 60fps batched writes with DEC 2026 sync |
+| **Local echo overlay** | `app.js` | `LocalEchoOverlay` class | DOM overlay for instant mobile keystroke feedback |
+| **Mobile support** | `mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` | Touch input, viewport adaptation, swipe navigation |
+| **Keyboard accessory** | `keyboard-accessory.js` | `KeyboardAccessoryBar`, `FocusTrap` | Mobile keyboard toolbar, modal focus management |
+| **Subagent windows** | `subagent-windows.js` | `openSubagentWindow()`, `closeSubagentWindow()`, `updateConnectionLines()` | Floating terminal windows with parent connection lines |
+| **Notifications** | `notification-manager.js` | `NotificationManager` class | 5-layer: in-app drawer, tab flash, browser API, web push, audio beep |
+| **Voice input** | `voice-input.js` | `DeepgramProvider`, `VoiceInput` | Speech-to-text via Deepgram WebSocket |
+| **SSE connection** | `app.js` | `connectSSE()`, `addListener()` | EventSource with exponential backoff (1-30s), offline queue (64KB) |
+| **Settings** | `app.js` | `openAppSettings()`, `apply*Visibility()` | Server-backed + localStorage persistence |
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
@@ -286,21 +296,22 @@ The frontend is a single ~15K-line vanilla JS file with these key systems:
### API Route Categories
-~280 route handlers split across `src/web/routes/` domain modules. Key groups:
+~110 route handlers split across `src/web/routes/` domain modules. Key groups:
| Group | Prefix | Count | Key endpoints |
|-------|--------|-------|---------------|
-| Sessions | `/api/sessions` | ~20 | CRUD, input, resize, interactive, shell |
-| Respawn | `/api/sessions/:id/respawn` | 5 | start, stop, enable, config |
-| Ralph | `/api/sessions/:id/ralph-*` | 6 | state, status, config, circuit-breaker |
-| Plan | `/api/sessions/:id/plan/*` | 5 | task CRUD, checkpoint, history, rollback |
+| Sessions | `/api/sessions` | ~24 | CRUD, input, resize, interactive, shell |
+| System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings` | ~32 | App state, config |
+| Ralph | `/api/sessions/:id/ralph-*` | 9 | state, status, config, circuit-breaker |
+| Plan | `/api/sessions/:id/plan/*` | 8 | task CRUD, checkpoint, history, rollback |
+| Respawn | `/api/sessions/:id/respawn` | 7 | start, stop, enable, config |
| Subagents | `/api/subagents` | 7 | list, transcript, kill, cleanup |
-| Cases | `/api/cases` | 5 | CRUD, link, fix-plan |
+| Cases | `/api/cases` | 7 | CRUD, link, fix-plan |
+| Files | `/api/sessions/:id/file*`, `tail-file` | 5 | Browser, preview, raw, tail stream |
+| Mux | `/api/mux-sessions` | 5 | tmux management, stats |
| Scheduled | `/api/scheduled` | 4 | CRUD for scheduled runs |
| Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe |
-| System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings` | 8 | App state, config |
-| Files | `/api/sessions/:id/file*`, `tail-file` | 5 | Browser, preview, raw, tail stream |
-| Mux | `/api/mux-sessions` | 4 | tmux management, stats |
+| Teams | `/api/teams` | 2 | list teams, get team tasks |
## Adding Features
@@ -448,7 +459,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
| **SSE events** | Search `broadcast(` in `server.ts` and route modules |
| **Session statuses** | `SessionStatus` in `src/types/session.ts` |
| **Error codes** | `createErrorResponse()` in `src/types/api.ts` |
-| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase-4-domain-splitting-plan.md` |
+| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase5-frontend-modularization-plan.md` |
| **Test utilities** | `test/respawn-test-utils.ts` |
| **Mobile test suite** | `mobile-test/README.md` |
| **OpenCode integration** | `docs/opencode-integration.md` |
diff --git a/docs/phase5-frontend-modularization-plan.md b/docs/phase5-frontend-modularization-plan.md
new file mode 100644
index 00000000..5fbf46a1
--- /dev/null
+++ b/docs/phase5-frontend-modularization-plan.md
@@ -0,0 +1,1143 @@
+# 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 `
+
@@ -1669,7 +1670,14 @@
-
-
+
+
+
+
+
+
+
+
+