Files
Codeman/docs/phase5-frontend-modularization-plan.md
T
arkonandClaude Opus 4.6 f44f0a912f refactor: split app.js into focused frontend modules (phase 5)
Extract 8 modules from the 15K-line app.js monolith:
- constants.js: shared constants, timing, escapeHtml(), extractSyncSegments()
- mobile-handlers.js: MobileDetection, KeyboardHandler, SwipeHandler
- voice-input.js: DeepgramProvider, VoiceInput
- notification-manager.js: NotificationManager class
- keyboard-accessory.js: KeyboardAccessoryBar, FocusTrap
- api-client.js: _api(), _apiJson(), _apiPost(), _apiDelete() helpers
- subagent-windows.js: 13 subagent window methods (open, close, drag, lines)
- vendor/xterm-zerolag-input.js: IIFE build replacing inlined copy

app.js reduced from ~15,200 to ~11,500 lines. All modules use global
scope with <script defer> ordering. Prototype extensions use
Object.assign(CodemanApp.prototype, {...}) pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:57:37 +01:00

46 KiB
Raw Blame History

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
  2. Architectural Decisions
  3. Module Dependency Graph
  4. Step 1: Extract Constants & Utilities
  5. Step 2: Extract Mobile Handlers
  6. Step 3: Extract Voice Input
  7. Step 4: Extract NotificationManager
  8. Step 5: Extract FocusTrap & KeyboardAccessoryBar
  9. Step 6: Replace Inlined xterm-zerolag-input
  10. Step 7: Create ApiClient Wrapper
  11. Step 8: Extract Subagent Window Manager
  12. Execution Order & Dependencies
  13. Validation Checklist
  14. 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.

// 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:

// File loaded after app.js via <script defer src="ralph-wizard.js">
Object.assign(CodemanApp.prototype, {
  methodName() { /* has access to `this` = CodemanApp instance */ },
  anotherMethod() { ... },
});

Key properties of this pattern:

  • No import/export — uses globals (vanilla JS, no bundler)
  • Object.assign adds methods to prototype at load time
  • Loaded with defer after app.js — guarantees CodemanApp exists
  • Zero circular dependencies (one-way from module → CodemanApp prototype)
  • HTML controls load order via <script defer> tag ordering

2. Architectural Decisions

Decision 1: Vanilla JS + Script Tags (No Bundler)

Rationale: The frontend is a vanilla JS application with no build step. Introducing a bundler (webpack, Vite, rollup) would:

  • Add build complexity and tooling dependencies
  • Break the current npm run dev workflow (just serves files directly)
  • Require source maps for debugging
  • Conflict with the hot-reload-free development model

Alternative considered: ES modules with <script type="module">. Rejected because:

  • Would require rewriting all globals as imports/exports
  • typeof X !== 'undefined' guards would need replacement
  • Service worker and index.html inline scripts would need adaptation
  • Bigger migration risk for limited benefit at this stage

Decision: Continue with <script defer> tags and global objects. Each extracted file defines a global (object, class, or set of functions) that subsequent scripts can access. This matches ralph-wizard.js.

Decision 2: Extraction Scope

Extract pre-CodemanApp top-level objects first (Steps 1-6), then CodemanApp methods using Object.assign (Steps 7-8). Do NOT attempt to break up the CodemanApp constructor or its core session/terminal management in this phase — that's a Phase 6+ effort requiring more invasive changes.

Decision 3: Module Granularity

Target 5-8 new files at 100-700 lines each. Avoid both micro-modules (<50 lines, not worth the HTTP request) and oversized modules (>1000 lines, defeats purpose). Each file should represent a cohesive domain with clear boundaries.

Decision 4: File Naming Convention

Follow existing convention: kebab-case.js in src/web/public/. No subdirectories (keeps serving simple).


3. Module Dependency Graph

                     index.html (load order)
                          │
              ┌───────────┤ (defer scripts, in order)
              │           │
    vendor/xterm*.js    constants.js (NEW)
              │           │
              │     mobile-handlers.js (NEW)
              │           │
              │     voice-input.js (NEW)
              │           │
              │     notification-manager.js (NEW)
              │           │
              │     keyboard-accessory.js (NEW)
              │           │
              │           │ (xterm-zerolag-input — inline or import from packages/)
              │           │
              ├───────────┤
              │           │
              │        app.js (REDUCED — ~10,500 lines → ~8,500 lines)
              │           │
              │     ralph-wizard.js (existing)
              │           │
              │     api-client.js (NEW — Object.assign to prototype)
              │           │
              │     subagent-windows.js (NEW — Object.assign to prototype)
              └───────────┘

Key constraint: Each script can only reference globals defined by scripts loaded BEFORE it. This is enforced by <script defer> ordering in index.html.


Step 1: Extract Constants & Utilities

New file: src/web/public/constants.js Lines moved from app.js: 1-159 (constants), 273-285 (getEventCoords), 2173-2215 (extractSyncSegments) Estimated size: ~230 lines Time: ~1 hour

What Moves

All top-level constants (lines 1-159):

// 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const _htmlEscapePattern = /[&<>"']/g;
function escapeHtml(text) {
  if (typeof text !== 'string') return '';
  return text.replace(_htmlEscapePattern, (ch) => _htmlEscapeMap[ch]);
}

// Unified event coordinate extraction (mouse + touch)
function getEventCoords(e) { ... }

What Changes in app.js

  1. Delete lines 1-159 (constants section) — moved to constants.js
  2. Delete lines 273-285 (getEventCoords) — moved to constants.js
  3. Delete lines 2173-2215 (extractSyncSegments) — moved to constants.js
  4. Delete escapeHtml instance method from CodemanApp class (lines 15162-15175, including static maps) — replaced by global escapeHtml() in constants.js
  5. Delete escapeHtml instance method from NotificationManager class (line 2659-2662) — replaced by global
  6. Replace this.escapeHtml(...) calls throughout CodemanApp (~70 occurrences) with escapeHtml(...) (global)
  7. Replace this.escapeHtml(...) calls in NotificationManager (lines 2461-2467, 4 calls) with escapeHtml(...)

What Changes in ralph-wizard.js

Replace 3 this.escapeHtml(...) calls (lines 364, 727, 850) with escapeHtml(...) (global function from constants.js, loaded before both app.js and ralph-wizard.js).

What Changes in index.html

Add the new script tag BEFORE app.js:

<script defer src="constants.js?v=VERSION"></script>
<script defer src="app.js?v=VERSION"></script>
<script defer src="ralph-wizard.js?v=VERSION"></script>

Verification

  1. All constant names are already globals — no reference changes needed in app.js for constants
  2. escapeHtml usage changes from this.escapeHtml() → escapeHtml() (global) in CodemanApp, NotificationManager, and ralph-wizard.js
  3. extractSyncSegments is already called as a global function — no change needed
  4. Browser test: load page, verify terminal renders, tabs work, notifications fire

Step 2: Extract Mobile Handlers

New file: src/web/public/mobile-handlers.js Lines moved from app.js: 168-628 (MobileDetection, KeyboardHandler, SwipeHandler) Estimated size: ~460 lines Time: ~2 hours

What Moves

Three related mobile objects that form a cohesive unit:

// 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

<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

  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

// 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

<script defer src="constants.js?v=VERSION"></script>
<script defer src="mobile-handlers.js?v=VERSION"></script>
<script defer src="voice-input.js?v=VERSION"></script>  <!-- NEW -->
<script defer src="app.js?v=VERSION"></script>
<script defer src="ralph-wizard.js?v=VERSION"></script>

Important: VoiceInput ↔ CodemanApp References

VoiceInput accesses app through typeof app !== 'undefined' guards in these cases:

  • app.activeSessionId — to know which session receives voice input
  • app.terminal — to focus terminal after voice input
  • app._sendInputAsync() — to send transcribed text
  • app.notificationManager — to show voice-related notifications
  • app.sessions.get() — to check session state

All of these use the existing guard pattern and will continue to work because app is initialized in app.js which loads after voice-input.js. The guards handle the case where VoiceInput.init() is called before app exists (which doesn't happen in practice, but is safe regardless).

Verification

  1. Load page — verify no console errors related to voice/Deepgram
  2. If Deepgram API key is configured in settings, test voice toggle
  3. Verify typeof VoiceInput !== 'undefined' checks in app.js still resolve correctly

Step 4: Extract NotificationManager

New file: src/web/public/notification-manager.js Lines moved from app.js: 2218-2663 (NotificationManager class) Estimated size: ~450 lines Time: ~2 hours

What Moves

// 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:
    this.notificationManager = new NotificationManager(this);
    
    This works because notification-manager.js loads before 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>
<script defer src="notification-manager.js?v=VERSION"></script>  <!-- NEW -->
<script defer src="app.js?v=VERSION"></script>
<script defer src="ralph-wizard.js?v=VERSION"></script>

Verification

  1. Load page — verify notification badge appears in header
  2. Trigger a notification event (e.g., create a session, send input) — verify:
    • In-app notification appears in drawer
    • Tab title flashes when not focused
    • Browser notification appears if enabled
  3. Open Settings → Notifications — verify preferences load/save correctly

Step 5: Extract FocusTrap & KeyboardAccessoryBar

New file: src/web/public/keyboard-accessory.js Lines moved from app.js: 1480-1748 (KeyboardAccessoryBar, FocusTrap) Estimated size: ~270 lines Time: ~1 hour

Why Combine These Two?

FocusTrap (60 lines) is too small for its own file but doesn't naturally belong in any other module. KeyboardAccessoryBar (200 lines) is mobile-focused but depends on MobileDetection (which is in mobile-handlers.js). Combining them creates a cohesive "keyboard interaction" module at a reasonable size.

What Moves

// 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

<script defer src="constants.js?v=VERSION"></script>
<script defer src="mobile-handlers.js?v=VERSION"></script>
<script defer src="voice-input.js?v=VERSION"></script>
<script defer src="notification-manager.js?v=VERSION"></script>
<script defer src="keyboard-accessory.js?v=VERSION"></script>  <!-- NEW -->
<script defer src="app.js?v=VERSION"></script>
<script defer src="ralph-wizard.js?v=VERSION"></script>

Verification

  1. Load page on mobile viewport — verify accessory bar appears when keyboard opens
  2. Test arrow keys, /init, /clear, /compact, paste buttons in accessory bar
  3. Open a modal (e.g., Settings) — verify Tab key cycles within modal (FocusTrap)
  4. Open Ralph Wizard — verify focus trap works there too

Step 6: Replace Inlined xterm-zerolag-input

Lines removed from app.js: 1756-2153 (~398 lines of inlined overlay code) New script tag: Load built file from packages/xterm-zerolag-input/ Time: ~1.5 hours

Current Problem

app.js contains a full copy of the xterm-zerolag-input package (7 helper functions + ZerolagInputAddon class + LocalEchoOverlay subclass). This code is also maintained in packages/xterm-zerolag-input/src/. Changes must be applied to both copies.

Strategy

Build the package and serve its output as a separate script, then remove the inlined copy from app.js.

Steps

  1. Build the package:

    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:

export default defineConfig({
  // ... existing config
  format: ['esm', 'cjs', 'iife'],
  globalName: 'XtermZerolagInput',
});

Then in the built IIFE file, LocalEchoOverlay would be available as XtermZerolagInput.LocalEchoOverlay.

  1. Copy built IIFE to vendor/:

    cp packages/xterm-zerolag-input/dist/index.global.js src/web/public/vendor/xterm-zerolag-input.js
    
  2. Add to index.html (after xterm.js, before app.js):

    <script defer src="vendor/xterm-zerolag-input.js?v=VERSION"></script>
    
  3. Update app.js: Delete lines 1756-2153 (inlined copy). In CodemanApp where LocalEchoOverlay is 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 LocalEchoOverlay as 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:

  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:

// ============================================================================
// 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:

// 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:

  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

<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

  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:

// 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

<script defer src="app.js?v=VERSION"></script>
<script defer src="ralph-wizard.js?v=VERSION"></script>
<script defer src="api-client.js?v=VERSION"></script>
<script defer src="subagent-windows.js?v=VERSION"></script>  <!-- NEW -->

Verification

  1. Start a session that spawns subagents — verify floating windows appear
  2. Drag a window — verify smooth movement
  3. Resize a window — verify constraints (min width/height)
  4. Minimize a window — verify badge appears on session tab
  5. Click badge — verify dropdown, click to restore
  6. Switch sessions — verify windows for other session hide
  7. Mobile viewport — verify windows stack from bottom when keyboard visible
  8. Open a teammate terminal window — verify makeWindowDraggable still works from app.js

Execution Order & Dependencies

Execute steps in this order. Each step is independently deployable and verifiable.

Step 1: constants.js              (self-contained, no deps)
  ↓
Step 2: mobile-handlers.js        (depends on constants.js for getEventCoords)
  ↓
Step 3: voice-input.js            (standalone; only needs `app` at runtime via typeof guard)
  ↓
Step 4: notification-manager.js   (depends on mobile-handlers.js for MobileDetection)
  ↓
Step 5: keyboard-accessory.js     (depends on mobile-handlers.js for MobileDetection)
  ↓
Step 6: xterm-zerolag-input       (depends on vendor/xterm.js)
  ↓
Step 7: api-client.js             (depends on CodemanApp — loads after app.js)
  ↓
Step 8: subagent-windows.js       (depends on CodemanApp — loads after app.js)

Parallelization opportunities:

  • Steps 3 + 5 can run in parallel (no cross-dependencies)
  • Steps 4 + 5 can run in parallel (both depend on mobile-handlers but not each other)
  • Steps 7 + 8 can run in parallel (both extend CodemanApp prototype independently)

index.html Final Script Order

<!-- Vendor: xterm.js + addons -->
<script defer src="vendor/xterm.min.js"></script>
<script defer src="vendor/xterm-addon-fit.min.js"></script>
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script>     <!-- Step 6 -->

<!-- Codeman: utilities & standalone modules (no CodemanApp dependency) -->
<script defer src="constants.js"></script>                       <!-- Step 1 -->
<script defer src="mobile-handlers.js"></script>                 <!-- Step 2 -->
<script defer src="voice-input.js"></script>                     <!-- Step 3 -->
<script defer src="notification-manager.js"></script>            <!-- Step 4 -->
<script defer src="keyboard-accessory.js"></script>              <!-- Step 5 -->

<!-- Codeman: core application -->
<script defer src="app.js"></script>                             <!-- REDUCED -->

<!-- Codeman: prototype extensions (loaded after CodemanApp defined) -->
<script defer src="ralph-wizard.js"></script>                    <!-- Existing -->
<script defer src="api-client.js"></script>                      <!-- Step 7 -->
<script defer src="subagent-windows.js"></script>                <!-- Step 8 -->

Validation Checklist

After EACH step, verify:

  • Page loads without console errors
  • All <script defer> tags reference correct files with updated ?v= versions
  • Terminals render and accept input
  • Session tabs work (create, switch, delete, drag reorder)
  • Notifications fire (create session → notification in drawer)
  • Subagent windows appear when Claude spawns agents
  • Mobile viewport works (resize to ≤768px, verify keyboard, swipe, accessory bar)
  • npm run lint passes (app.js is excluded from lint, but check for syntax errors)
  • npm run format:check passes
  • Dev server restarts cleanly: npx tsx src/index.ts web

Additional checks after all steps complete:

  • Local echo overlay shows instant keystrokes
  • Ralph wizard opens and functions
  • Voice input toggle works (if configured)
  • Settings modal opens with focus trap
  • Respawn UI (timer banner, countdown) functions
  • Run du -b src/web/public/app.js — target: ≤10,000 lines (down from 15,196)

Size Targets

File Before After
app.js 15,196 lines ~11,500 lines
constants.js — ~230 lines
mobile-handlers.js — ~460 lines
voice-input.js — ~850 lines
notification-manager.js — ~450 lines
keyboard-accessory.js — ~270 lines
api-client.js — ~150 lines
subagent-windows.js — ~700 lines
ralph-wizard.js (existing) 1,037 lines 1,037 lines (unchanged)
Total new files — 7 files
Net LOC change — ~0 (refactor only)
app.js reduction — ~3,700 lines removed (~24%)

Risk Assessment

Risk Likelihood Impact Mitigation
Script load order breaks a cross-reference Medium HIGH All existing typeof X !== 'undefined' guards handle missing globals; verify with manual browser test after each step
Object.assign prototype methods unavailable at call time Low HIGH JS prototype lookup is dynamic — works even when methods added after instantiation. Test key flows (input, delete, window open)
Missing constant in extracted file Low MEDIUM TypeScript won't help (vanilla JS). Search for all constant names before extraction with grep -n 'CONSTANT_NAME' app.js
Cache busting failure (users see stale JS) Medium MEDIUM Always update ?v= version string. Note: production caches static files for 1 hour (maxAge: '1h'). Users may need Ctrl+Shift+R
Mobile regression (keyboard, swipe, overlay) Medium HIGH Test on actual mobile device or DevTools device mode after Steps 2, 3, 5
Performance regression from additional HTTP requests Low LOW 7 new script files × ~5KB each = ~35KB total. With HTTP/2 multiplexing and browser caching, negligible impact. All files are defer (non-blocking)
ralph-wizard.js breaks after escapeHtml change Low MEDIUM Step 1 migrates this.escapeHtml() → escapeHtml() in ralph-wizard.js. Verify all 3 call sites (lines 364, 727, 850)
Non-contiguous extraction in Step 8 Medium HIGH Subagent methods span 3 regions of app.js. Use grep to verify all references before and after extraction

What NOT to Touch in This Phase

  • CodemanApp constructor — Do not split the 42+ properties into sub-objects. That's a Phase 6+ effort requiring careful state management.
  • SSE event handling — The handleSSEEvent() method is the spine of the app. Keep it in app.js.
  • Terminal rendering pipeline — batchTerminalWrite(), flushPendingWrites(), chunkedTerminalWrite() are performance-critical and deeply coupled to xterm.js state. Keep in app.js.
  • selectSession() — 266-line method with complex state transitions. Keep in app.js for now.
  • renderAppSettings() — Settings UI is deeply coupled to server-backed settings. Keep in app.js.
  • Session lifecycle methods — quickStartSession(), deleteSession(), stopSession() — these coordinate too many subsystems to extract cleanly.

Patterns to Preserve

These working patterns should NOT be changed:

  • typeof X !== 'undefined' guards for optional globals
  • Object.assign(CodemanApp.prototype, ...) for method injection
  • <script defer> for load ordering
  • window.app = app for debugging access
  • app._elemCache for DOM element caching
  • All requestAnimationFrame-based batching in terminal writes

Future Phases (Not in Scope)

For reference, these are natural follow-up phases after Phase 5:

Phase 6 — Config Consolidation (from findings roadmap):

  • Create src/config/server-config.ts, src/config/timing-config.ts
  • Move 40+ scattered backend constants

Phase 7 — Test Infrastructure (from findings roadmap):

  • Consolidate MockSession into test/mocks/
  • Add server.ts route tests

Phase 8 — CodemanApp Class Decomposition (future, requires Phase 5):

  • Extract SessionUI class (session lifecycle, tab management)
  • Extract TerminalRenderer class (batching, sync, flicker filter)
  • Extract SubagentTracker class (agent discovery, tool call tracking)
  • Extract RalphStateUI class (Ralph panel, state rendering)
  • Extract SSEConnector class (EventSource, reconnect, backoff)
  • Reduce CodemanApp to orchestrator (~2000 lines)

Phase 9 — ES Module Migration (future, if bundler adopted):

  • Convert all .js files to ES modules
  • Replace globals with import/export
  • Add build step with Vite or esbuild
  • Enable tree-shaking