CLAUDE.md: fix stale counts (types 14 to 15, SSE events ~118 to ~120), remove redundant footer sections (References list duplicated inline citations; Common Workflows bullets were self-evident or already stated; Tunnel/Memory Leak Prevention folded into neighboring sections). 251 to 234 lines. Move 22 completed implementation/phase/audit plans to docs/archive/ via git mv so history is preserved. Living reference docs remain in docs/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46 KiB
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
- Current State Analysis
- Architectural Decisions
- Module Dependency Graph
- Step 1: Extract Constants & Utilities
- Step 2: Extract Mobile Handlers
- Step 3: Extract Voice Input
- Step 4: Extract NotificationManager
- Step 5: Extract FocusTrap & KeyboardAccessoryBar
- Step 6: Replace Inlined xterm-zerolag-input
- Step 7: Create ApiClient Wrapper
- Step 8: Extract Subagent Window Manager
- Execution Order & Dependencies
- Validation Checklist
- Risk Assessment
Safety Constraints
Before starting ANY work, read and follow these rules:
- Never run
npx vitest run(full suite) — it kills tmux sessions. You are running inside a Codeman-managed tmux session. - Never test on port 3000 — the live dev server runs there. Tests use ports 3150+.
- After TypeScript changes: Run
tsc --noEmitto verify type checking passes. - Before considering done: Run
npm run lintandnpm run format:checkto ensure CI passes. - Never kill tmux sessions — check
echo $CODEMAN_MUXfirst. - Frontend changes require browser verification: After each step, use Playwright to load the page and verify the UI renders. Use
waitUntil: 'domcontentloaded'(notnetworkidle— SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values. - Cache busting: Update the
?v=query strings inindex.htmlfor any modified/new JS files after each step. - Verify dev server:
npx tsx src/index.ts web --port 3099 &on a non-production port, confirmcurl -s http://localhost:3099/api/status | jq .statusreturns"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.
// 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:
NotificationManager.escapeHtml()(line 2659) — instance method, used internally byNotificationManagerat lines 2461-2467CodemanApp.escapeHtml()(line 15172) — instance method with static lookup maps (_htmlEscapeMap,_htmlEscapePattern) for performance. Used ~70 times throughout CodemanApp, and byralph-wizard.js(3 calls viathis.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:
// 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.assignadds methods to prototype at load time- Loaded with
deferafterapp.js— guaranteesCodemanAppexists - 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 devworkflow (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.htmlinline 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):
// 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
- Delete lines 1-159 (constants section) — moved to
constants.js - Delete lines 273-285 (
getEventCoords) — moved toconstants.js - Delete lines 2173-2215 (
extractSyncSegments) — moved toconstants.js - Delete
escapeHtmlinstance method from CodemanApp class (lines 15162-15175, including static maps) — replaced by globalescapeHtml()inconstants.js - Delete
escapeHtmlinstance method from NotificationManager class (line 2659-2662) — replaced by global - Replace
this.escapeHtml(...)calls throughout CodemanApp (~70 occurrences) withescapeHtml(...)(global) - Replace
this.escapeHtml(...)calls in NotificationManager (lines 2461-2467, 4 calls) withescapeHtml(...)
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:
<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
- All constant names are already globals — no reference changes needed in app.js for constants
escapeHtmlusage changes fromthis.escapeHtml()→escapeHtml()(global) in CodemanApp, NotificationManager, and ralph-wizard.jsextractSyncSegmentsis already called as a global function — no change needed- 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:
// 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
- Delete lines 160-628 (MobileDetection section header through end of SwipeHandler)
getEventCoords()(lines 273-285) already moved toconstants.jsin Step 1 — verify deleted- No reference changes needed — all three objects are already globals accessed by name
What Changes in index.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:
// 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
- Load page on desktop — verify MobileDetection sets correct body classes
- Load page on mobile viewport (DevTools device mode, ≤768px) — verify:
- Touch detection works
- Keyboard show/hide repositions toolbar
- Swipe left/right switches sessions
- Check that
window.MobileDetectionis still set (line 15196 of app.js).MobileDetectionis now a global frommobile-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
// 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
- Delete lines 631-1479 (DeepgramProvider + VoiceInput sections)
- No reference changes —
VoiceInputandDeepgramProviderare accessed as globals throughout app.js
What Changes in index.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 inputapp.terminal— to focus terminal after voice inputapp._sendInputAsync()— to send transcribed textapp.notificationManager— to show voice-related notificationsapp.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
- Load page — verify no console errors related to voice/Deepgram
- If Deepgram API key is configured in settings, test voice toggle
- 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
// 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
- Delete lines 2218-2663 (NotificationManager class, including the
relativeTimeand oldescapeHtmlutilities at lines 2650-2662) - The CodemanApp constructor already creates the instance:
This works because
this.notificationManager = new NotificationManager(this);notification-manager.jsloads beforeapp.js.
What Changes in index.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
- Load page — verify notification badge appears in header
- 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
- 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
// 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
- Delete lines 1480-1748 (KeyboardAccessoryBar + FocusTrap)
What Changes in index.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
- Load page on mobile viewport — verify accessory bar appears when keyboard opens
- Test arrow keys, /init, /clear, /compact, paste buttons in accessory bar
- Open a modal (e.g., Settings) — verify Tab key cycles within modal (FocusTrap)
- 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
-
Build the package:
cd packages/xterm-zerolag-input npm run buildThis produces
dist/output via tsup. -
Check build output format: The package's
tsup.config.tsbuilds for ESM/CJS. We need an IIFE or global build that exposesLocalEchoOverlayas 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:
export default defineConfig({
// ... existing config
format: ['esm', 'cjs', 'iife'],
globalName: 'XtermZerolagInput',
});
Then in the built IIFE file, LocalEchoOverlay would be available as XtermZerolagInput.LocalEchoOverlay.
-
Copy built IIFE to vendor/:
cp packages/xterm-zerolag-input/dist/index.global.js src/web/public/vendor/xterm-zerolag-input.js -
Add to index.html (after xterm.js, before app.js):
<script defer src="vendor/xterm-zerolag-input.js?v=VERSION"></script> -
Update app.js: Delete lines 1756-2153 (inlined copy). In CodemanApp where
LocalEchoOverlayis instantiated, update the reference:// 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
LocalEchoOverlayas a direct global:// 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:
- Edit source in
packages/xterm-zerolag-input/src/ - Run
npm run buildin the package directory - Copy built output to
src/web/public/vendor/ - 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:
// ============================================================================
// 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
- Load page — verify terminal renders
- Type text — verify local echo overlay shows instant keystroke feedback
- Type on mobile — verify overlay appears above keyboard
- 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:
// 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.
// 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:
- Add the
_api/_apiJson/_apiPost/_apiDeletehelpers viaObject.assign - Migrate 10 of the most repeated patterns as proof of concept:
- Session input:
_sendInputAsync() - Session resize: in
KeyboardHandler._sendTerminalResize()andsetupEventListeners() - 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()
- Session input:
- Leave remaining fetch calls for incremental migration in future PRs
What Changes in index.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:
// 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
- Load page — verify no console errors
- Create a session — verify
quickStartSession()works - Send input — verify text is delivered
- Delete a session — verify it's removed
- 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:
// 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,subagentWindowZIndexproperties stay in the CodemanApp constructor (they are state, not methods) - SSE event handlers for
subagent:discovered,subagent:updated,subagent:completedstay inhandleSSEEvent()— 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:
- Cut each method completely (including any JSDoc comments above it)
- Leave surrounding methods intact
- 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
<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
- Start a session that spawns subagents — verify floating windows appear
- Drag a window — verify smooth movement
- Resize a window — verify constraints (min width/height)
- Minimize a window — verify badge appears on session tab
- Click badge — verify dropdown, click to restore
- Switch sessions — verify windows for other session hide
- Mobile viewport — verify windows stack from bottom when keyboard visible
- Open a teammate terminal window — verify
makeWindowDraggablestill 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
<!-- 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 lintpasses (app.js is excluded from lint, but check for syntax errors)npm run format:checkpasses- 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 globalsObject.assign(CodemanApp.prototype, ...)for method injection<script defer>for load orderingwindow.app = appfor debugging accessapp._elemCachefor 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
SessionUIclass (session lifecycle, tab management) - Extract
TerminalRendererclass (batching, sync, flicker filter) - Extract
SubagentTrackerclass (agent discovery, tool call tracking) - Extract
RalphStateUIclass (Ralph panel, state rendering) - Extract
SSEConnectorclass (EventSource, reconnect, backoff) - Reduce CodemanApp to orchestrator (~2000 lines)
Phase 9 — ES Module Migration (future, if bundler adopted):
- Convert all
.jsfiles to ES modules - Replace globals with import/export
- Add build step with Vite or esbuild
- Enable tree-shaking