From f44f0a912f22541155300f29b577bd9497cf8063 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 1 Mar 2026 04:57:37 +0100 Subject: [PATCH] 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 + + +``` + +### 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 @@ - - + + + + + + + + + diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js new file mode 100644 index 00000000..2dd7c6ab --- /dev/null +++ b/src/web/public/keyboard-accessory.js @@ -0,0 +1,279 @@ +// Codeman — Keyboard accessory bar and focus trap for modals +// Loaded after mobile-handlers.js, before app.js + +// ============================================================================ +// Mobile Keyboard Accessory Bar +// ============================================================================ + +/** + * KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing. + */ +const KeyboardAccessoryBar = { + element: null, + + /** Create and inject the accessory bar */ + init() { + // Only on mobile + if (!MobileDetection.isTouchDevice()) return; + + // Create accessory bar element + this.element = document.createElement('div'); + this.element.className = 'keyboard-accessory-bar'; + this.element.innerHTML = ` + + + + + + + + `; + + // Add click handlers — preventDefault stops event from reaching terminal + this.element.addEventListener('click', (e) => { + const btn = e.target.closest('.accessory-btn'); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + + const action = btn.dataset.action; + this.handleAction(action, btn); + + // Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts) + if ((action === 'scroll-up' || action === 'scroll-down') || + ((action === 'clear' || action === 'compact') && this._confirmAction)) { + if (typeof app !== 'undefined' && app.terminal) { + app.terminal.focus(); + } + } + }); + + // Insert before toolbar + const toolbar = document.querySelector('.toolbar'); + if (toolbar && toolbar.parentNode) { + toolbar.parentNode.insertBefore(this.element, toolbar); + } + }, + + _confirmTimer: null, + _confirmAction: null, + + /** Handle accessory button actions */ + handleAction(action, btn) { + if (typeof app === 'undefined' || !app.activeSessionId) return; + + switch (action) { + case 'scroll-up': + this.sendKey('\x1b[A'); + break; + case 'scroll-down': + this.sendKey('\x1b[B'); + break; + case 'init': + this.sendCommand('/init'); + break; + case 'clear': + case 'compact': { + // Require double-tap: first tap turns amber, second tap within 2s sends + const cmd = action === 'clear' ? '/clear' : '/compact'; + if (this._confirmAction === action && this._confirmTimer) { + this.clearConfirm(); + this.sendCommand(cmd); + } else { + this.setConfirm(action, btn); + } + break; + } + case 'paste': + this.pasteFromClipboard(); + break; + case 'dismiss': + // Blur active element to dismiss keyboard + document.activeElement?.blur(); + break; + } + }, + + /** Enter confirm state: button turns amber for 2s waiting for second tap */ + setConfirm(action, btn) { + this.clearConfirm(); + this._confirmAction = action; + if (btn) { + btn.classList.add('confirming'); + btn.dataset.origHtml = btn.innerHTML; + btn.textContent = 'Tap again'; + } + this._confirmTimer = setTimeout(() => this.clearConfirm(), 2000); + }, + + /** Reset confirm state */ + clearConfirm() { + if (this._confirmTimer) { + clearTimeout(this._confirmTimer); + this._confirmTimer = null; + } + if (this._confirmAction && this.element) { + const btn = this.element.querySelector(`[data-action="${this._confirmAction}"]`); + if (btn && btn.dataset.origHtml) { + btn.innerHTML = btn.dataset.origHtml; + delete btn.dataset.origHtml; + } + if (btn) btn.classList.remove('confirming'); + } + this._confirmAction = null; + }, + + /** Send a slash command to the active session. + * Sends text and Enter separately so Ink processes them as distinct events. */ + sendCommand(command) { + if (!app.activeSessionId) return; + // Send command text first (without Enter) + app.sendInput(command); + // Send Enter separately after a brief delay so Ink has time to process the text. + setTimeout(() => app.sendInput('\r'), 120); + }, + + /** Send a special key (arrow, escape, etc.) directly to the PTY. + * Bypasses tmux send-keys -l (literal mode) since escape sequences + * must be written raw to be interpreted as key presses by Ink. */ + sendKey(escapeSequence) { + if (!app.activeSessionId) return; + fetch(`/api/sessions/${app.activeSessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: escapeSequence }) + }).catch(() => {}); + }, + + /** Read clipboard and send contents as input */ + /** Show a paste overlay with a textarea for iOS compatibility */ + pasteFromClipboard() { + if (typeof app === 'undefined' || !app.activeSessionId) return; + + // Create overlay + const overlay = document.createElement('div'); + overlay.className = 'paste-overlay'; + overlay.innerHTML = ` +
+ +
+ + +
+
+ `; + + const textarea = overlay.querySelector('.paste-textarea'); + const send = () => { + const text = textarea.value; + overlay.remove(); + if (text) app.sendInput(text); + }; + overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove()); + overlay.querySelector('.paste-send').addEventListener('click', send); + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + + document.body.appendChild(overlay); + textarea.focus(); + }, + + /** Show the accessory bar */ + show() { + if (this.element) { + this.element.classList.add('visible'); + } + }, + + /** Hide the accessory bar */ + hide() { + if (this.element) { + this.element.classList.remove('visible'); + } + } +}; + +// ============================================================================ +// Accessibility: Focus Trap for Modals +// ============================================================================ + +/** + * FocusTrap - Traps keyboard focus within an element (typically a modal). + * Saves the previously focused element and restores focus when deactivated. + */ +class FocusTrap { + constructor(element) { + this.element = element; + this.previouslyFocused = null; + this.boundHandleKeydown = this.handleKeydown.bind(this); + } + + activate() { + this.previouslyFocused = document.activeElement; + this.element.addEventListener('keydown', this.boundHandleKeydown); + + // Focus first focusable element after a brief delay (for CSS transitions) + requestAnimationFrame(() => { + const focusable = this.getFocusableElements(); + if (focusable.length) { + focusable[0].focus(); + } + }); + } + + deactivate() { + this.element.removeEventListener('keydown', this.boundHandleKeydown); + if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') { + this.previouslyFocused.focus(); + } + } + + getFocusableElements() { + const selector = [ + 'button:not([disabled]):not([tabindex="-1"])', + 'input:not([disabled]):not([tabindex="-1"])', + 'select:not([disabled]):not([tabindex="-1"])', + 'textarea:not([disabled]):not([tabindex="-1"])', + 'a[href]:not([tabindex="-1"])', + '[tabindex]:not([tabindex="-1"]):not([disabled])' + ].join(', '); + + return [...this.element.querySelectorAll(selector)].filter( + el => el.offsetParent !== null // Exclude hidden elements + ); + } + + handleKeydown(e) { + if (e.key !== 'Tab') return; + + const focusable = this.getFocusableElements(); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } +} diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js new file mode 100644 index 00000000..f0e7f195 --- /dev/null +++ b/src/web/public/mobile-handlers.js @@ -0,0 +1,449 @@ +// Codeman — Mobile detection, keyboard handling, and swipe navigation +// Loaded after constants.js, before app.js + +// ============================================================================ +// Mobile Detection +// ============================================================================ + +/** + * MobileDetection - Detects device type and touch capability. + * Updates body classes for CSS targeting. + */ +const MobileDetection = { + /** Check if device supports touch input */ + isTouchDevice() { + return 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + (window.matchMedia && window.matchMedia('(pointer: coarse)').matches); + }, + + /** Check if device is iOS (iPhone, iPad, iPod) */ + isIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + }, + + /** Check if browser is Safari */ + isSafari() { + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + }, + + /** Check if screen is small (phone-sized, <430px) */ + isSmallScreen() { + return window.innerWidth < 430; + }, + + /** Check if screen is medium (tablet-sized, 430-768px) */ + isMediumScreen() { + return window.innerWidth >= 430 && window.innerWidth < 768; + }, + + /** Get device type based on screen width */ + getDeviceType() { + const width = window.innerWidth; + if (width < 430) return 'mobile'; + if (width < 768) return 'tablet'; + return 'desktop'; + }, + + /** Update body classes based on device detection */ + updateBodyClass() { + const body = document.body; + const deviceType = this.getDeviceType(); + const isTouch = this.isTouchDevice(); + + // Remove existing device classes + body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser'); + + // Add current device class + body.classList.add(`device-${deviceType}`); + + // Add touch device class if applicable + if (isTouch) { + body.classList.add('touch-device'); + } + + // Add iOS-specific class for safe area handling + if (this.isIOS()) { + body.classList.add('ios-device'); + } + + // Add Safari class for browser-specific fixes + if (this.isSafari()) { + body.classList.add('safari-browser'); + } + }, + + /** Initialize mobile detection and set up resize listener */ + init() { + this.updateBodyClass(); + // Debounced resize handler + let resizeTimeout; + this._resizeHandler = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => this.updateBodyClass(), 100); + }; + window.addEventListener('resize', this._resizeHandler); + + // iOS: prevent pinch-to-zoom (Safari ignores user-scalable=no since iOS 10) + if (this.isIOS()) { + this._gestureStartHandler = (e) => e.preventDefault(); + this._gestureChangeHandler = (e) => e.preventDefault(); + document.addEventListener('gesturestart', this._gestureStartHandler); + document.addEventListener('gesturechange', this._gestureChangeHandler); + } + }, + + /** Remove event listeners */ + cleanup() { + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); + this._resizeHandler = null; + } + if (this._gestureStartHandler) { + document.removeEventListener('gesturestart', this._gestureStartHandler); + document.removeEventListener('gesturechange', this._gestureChangeHandler); + this._gestureStartHandler = null; + this._gestureChangeHandler = null; + } + } +}; + +// ============================================================================ +// Mobile Keyboard Handler +// ============================================================================ + +/** + * KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears. + * Uses focusin event and scrollIntoView - keeps it simple and reliable. + * Also handles terminal scrolling and toolbar repositioning via visualViewport API. + */ +const KeyboardHandler = { + lastViewportHeight: 0, + keyboardVisible: false, + initialViewportHeight: 0, + + /** Initialize keyboard handling */ + init() { + // Only initialize on touch devices + if (!MobileDetection.isTouchDevice()) return; + + this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; + this.lastViewportHeight = this.initialViewportHeight; + + // Simple focus handler - scroll input into view after keyboard appears + this._focusinHandler = (e) => { + const target = e.target; + if (!this.isInputElement(target)) return; + + // Wait for keyboard animation, then scroll input into view + setTimeout(() => { + this.scrollInputIntoView(target); + }, 400); + }; + document.addEventListener('focusin', this._focusinHandler); + + // Use visualViewport to detect keyboard and reposition toolbar + if (window.visualViewport) { + this._viewportResizeHandler = () => { + this.handleViewportResize(); + }; + this._viewportScrollHandler = () => { + this.updateLayoutForKeyboard(); + }; + window.visualViewport.addEventListener('resize', this._viewportResizeHandler); + // Also handle scroll (iOS scrolls viewport when keyboard appears) + window.visualViewport.addEventListener('scroll', this._viewportScrollHandler); + } + }, + + /** Remove event listeners */ + cleanup() { + if (this._focusinHandler) { + document.removeEventListener('focusin', this._focusinHandler); + this._focusinHandler = null; + } + if (this._viewportResizeHandler && window.visualViewport) { + window.visualViewport.removeEventListener('resize', this._viewportResizeHandler); + this._viewportResizeHandler = null; + } + if (this._viewportScrollHandler && window.visualViewport) { + window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler); + this._viewportScrollHandler = null; + } + }, + + /** Handle viewport resize (keyboard show/hide) */ + handleViewportResize() { + const currentHeight = window.visualViewport?.height || window.innerHeight; + const heightDiff = this.initialViewportHeight - currentHeight; + + // Keyboard appeared (viewport shrunk by more than 150px) + if (heightDiff > 150 && !this.keyboardVisible) { + this.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); + this.onKeyboardShow(); + } + // Keyboard hidden (viewport grew back close to initial) + // Use 100px threshold (not 50) to handle iOS address bar drift, + // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes + else if (heightDiff < 100 && this.keyboardVisible) { + this.keyboardVisible = false; + document.body.classList.remove('keyboard-visible'); + this.onKeyboardHide(); + } + + // Update baseline when keyboard is not visible — adapts to address bar + // state changes, orientation changes, and other viewport shifts + if (!this.keyboardVisible) { + this.initialViewportHeight = currentHeight; + } + + this.updateLayoutForKeyboard(); + this.lastViewportHeight = currentHeight; + }, + + /** Update layout when keyboard shows/hides */ + updateLayoutForKeyboard() { + if (!window.visualViewport) return; + + // Only adjust on mobile + if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) { + this.resetLayout(); + return; + } + + const toolbar = document.querySelector('.toolbar'); + const accessoryBar = document.querySelector('.keyboard-accessory-bar'); + const main = document.querySelector('.main'); + + if (this.keyboardVisible) { + // Calculate keyboard offset + const layoutHeight = window.innerHeight; + const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; + const keyboardOffset = layoutHeight - visualBottom; + + // Safety: if keyboard is supposedly visible but offset is 0 or negative, + // the keyboard is actually gone — force dismiss. This catches cases where + // visualViewport.resize fires late or with intermediate values on iOS. + if (keyboardOffset <= 0) { + this.keyboardVisible = false; + document.body.classList.remove('keyboard-visible'); + this.onKeyboardHide(); + return; + } + + // Move toolbar up above keyboard + if (toolbar) { + toolbar.style.transform = `translateY(${-keyboardOffset}px)`; + } + + // Move accessory bar up (it sits above toolbar) + if (accessoryBar) { + accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`; + } + + // Shrink main content area so terminal doesn't extend behind keyboard + // Account for keyboard height + toolbar height (40px) + accessory bar (44px) + if (main) { + main.style.paddingBottom = `${keyboardOffset + 94}px`; + } + } else { + this.resetLayout(); + } + }, + + /** Reset layout to normal (no keyboard) */ + resetLayout() { + const toolbar = document.querySelector('.toolbar'); + const accessoryBar = document.querySelector('.keyboard-accessory-bar'); + const main = document.querySelector('.main'); + + if (toolbar) { + toolbar.style.transform = ''; + } + if (accessoryBar) { + accessoryBar.style.transform = ''; + } + if (main) { + main.style.paddingBottom = ''; + } + }, + + /** Called when keyboard appears */ + onKeyboardShow() { + // Show keyboard accessory bar + if (typeof KeyboardAccessoryBar !== 'undefined') { + KeyboardAccessoryBar.show(); + } + + // Refit terminal locally AND send resize to server so Claude Code (Ink) + // knows the actual terminal dimensions. Without this, Ink redraws at the + // old (larger) row count when the user types, causing content to scroll + // off the visible area with each keystroke. + // Note: the throttledResize handler still suppresses ongoing resize events + // while keyboard is up — this one-shot resize on open/close is sufficient. + setTimeout(() => { + if (typeof app !== 'undefined' && app.terminal) { + if (app.fitAddon) try { app.fitAddon.fit(); } catch {} + app.terminal.scrollToBottom(); + // Send resize to server so PTY dimensions match xterm + this._sendTerminalResize(); + } + }, 150); + + // Reposition subagent windows to stack from bottom (above keyboard) + if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); + }, + + /** Called when keyboard hides */ + onKeyboardHide() { + // Hide keyboard accessory bar + if (typeof KeyboardAccessoryBar !== 'undefined') { + KeyboardAccessoryBar.hide(); + } + + this.resetLayout(); + + // Refit terminal, scroll to bottom, and send resize to restore original dimensions + setTimeout(() => { + if (typeof app !== 'undefined' && app.fitAddon) { + try { app.fitAddon.fit(); } catch {} + if (app.terminal) app.terminal.scrollToBottom(); + // Send resize to server to restore full terminal size + this._sendTerminalResize(); + } + }, 100); + + // Reposition subagent windows to stack from top (below header) + if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); + }, + + /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ + _sendTerminalResize() { + if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; + try { + const dims = app.fitAddon.proposeDimensions(); + if (dims) { + const cols = Math.max(dims.cols, 40); + const rows = Math.max(dims.rows, 10); + app._lastResizeDims = { cols, rows }; + fetch(`/api/sessions/${app.activeSessionId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols, rows }) + }).catch(() => {}); + } + } catch {} + }, + + /** Check if element is an input that triggers keyboard (excludes terminal) */ + isInputElement(el) { + if (!el) return false; + + // Exclude xterm.js terminal inputs (they handle their own scroll) + if (el.closest('.xterm') || el.closest('.terminal-container')) { + return false; + } + + const tagName = el.tagName?.toLowerCase(); + // Exclude type=range, type=checkbox, type=radio (don't trigger keyboard) + if (tagName === 'input') { + const type = el.type?.toLowerCase(); + if (type === 'checkbox' || type === 'radio' || type === 'range' || type === 'file') { + return false; + } + } + return ( + tagName === 'input' || + tagName === 'textarea' || + el.isContentEditable + ); + }, + + /** Scroll input into view above the keyboard */ + scrollInputIntoView(input) { + // Check if input is still focused (user might have tapped away) + if (document.activeElement !== input) return; + + // Find if we're in a modal + const modal = input.closest('.modal.active'); + const modalBody = modal?.querySelector('.modal-body'); + + if (modalBody) { + // For modals - scroll within the modal body + const inputRect = input.getBoundingClientRect(); + const modalRect = modalBody.getBoundingClientRect(); + + // If input is below middle of modal, scroll it up + if (inputRect.top > modalRect.top + modalRect.height * 0.4) { + const scrollAmount = inputRect.top - modalRect.top - 100; + modalBody.scrollBy({ top: scrollAmount, behavior: 'smooth' }); + } + } else { + // For page-level - use scrollIntoView + input.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + } +}; + +// ============================================================================ +// Mobile Swipe Handler +// ============================================================================ + +/** + * SwipeHandler - Detects horizontal swipes on terminal to switch sessions. + * Only active on mobile/touch devices. + */ +const SwipeHandler = { + startX: 0, + startY: 0, + startTime: 0, + minSwipeDistance: 80, // Minimum pixels for a valid swipe + maxSwipeTime: 300, // Maximum ms for a swipe gesture + maxVerticalDrift: 100, // Max vertical movement allowed + + /** Initialize swipe handling */ + init() { + // Only on touch devices + if (!MobileDetection.isTouchDevice()) return; + + const terminal = document.querySelector('.main'); + if (!terminal) return; + + terminal.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true }); + terminal.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true }); + }, + + onTouchStart(e) { + if (!e.touches || e.touches.length !== 1) return; + this.startX = e.touches[0].clientX; + this.startY = e.touches[0].clientY; + this.startTime = Date.now(); + }, + + onTouchEnd(e) { + if (!e.changedTouches || e.changedTouches.length !== 1) return; + + const endX = e.changedTouches[0].clientX; + const endY = e.changedTouches[0].clientY; + const elapsed = Date.now() - this.startTime; + + // Check if it's a valid swipe + const deltaX = endX - this.startX; + const deltaY = Math.abs(endY - this.startY); + + if (elapsed > this.maxSwipeTime) return; // Too slow + if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement + if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short + + // Valid swipe detected + if (deltaX > 0) { + // Swipe right -> previous session + if (typeof app !== 'undefined') app.prevSession(); + } else { + // Swipe left -> next session + if (typeof app !== 'undefined') app.nextSession(); + } + } +}; diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js new file mode 100644 index 00000000..aa29b1e2 --- /dev/null +++ b/src/web/public/notification-manager.js @@ -0,0 +1,445 @@ +// Codeman — Multi-layer notification system +// Loaded after mobile-handlers.js, before app.js + +// Notification Manager - Multi-layer browser notification system +class NotificationManager { + constructor(app) { + this.app = app; + this.notifications = []; + this.unreadCount = 0; + this.isTabVisible = !document.hidden; + this.isDrawerOpen = false; + this.originalTitle = document.title; + this.titleFlashInterval = null; + this.titleFlashState = false; + this.lastBrowserNotifTime = 0; + this.audioCtx = null; + this.renderScheduled = false; + + // Debounce grouping: Map + this.groupingMap = new Map(); + + // Load preferences + this.preferences = this.loadPreferences(); + + // Visibility tracking + document.addEventListener('visibilitychange', () => { + this.isTabVisible = !document.hidden; + if (this.isTabVisible) { + this.onTabVisible(); + } + }); + // iOS Safari: pageshow fires on back-forward cache restore (bfcache) + window.addEventListener('pageshow', (e) => { + if (e.persisted) { + this.isTabVisible = true; + this.onTabVisible(); + } + }); + } + + loadPreferences() { + const defaultEventTypes = { + permission_prompt: { enabled: true, browser: true, audio: true, push: false }, + elicitation_dialog: { enabled: true, browser: true, audio: true, push: false }, + idle_prompt: { enabled: true, browser: true, audio: false, push: false }, + stop: { enabled: true, browser: false, audio: false, push: false }, + session_error: { enabled: true, browser: true, audio: false, push: false }, + respawn_cycle: { enabled: true, browser: false, audio: false, push: false }, + token_milestone: { enabled: true, browser: false, audio: false, push: false }, + ralph_complete: { enabled: true, browser: true, audio: true, push: false }, + subagent_spawn: { enabled: false, browser: false, audio: false, push: false }, + subagent_complete: { enabled: false, browser: false, audio: false, push: false }, + }; + + // Device-specific defaults: mobile has notifications disabled by default + const isMobile = MobileDetection.getDeviceType() === 'mobile'; + const defaults = { + enabled: !isMobile, // Disabled on mobile by default + browserNotifications: !isMobile, + audioAlerts: false, + stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS, + // Legacy urgency muting (keep for backwards compat) + muteCritical: false, + muteWarning: false, + muteInfo: false, + // Per-event-type preferences + eventTypes: defaultEventTypes, + _version: 4, + }; + try { + const storageKey = this.getStorageKey(); + const saved = localStorage.getItem(storageKey); + if (saved) { + const prefs = JSON.parse(saved); + // Migrate: v1 had browserNotifications defaulting to false + if (!prefs._version || prefs._version < 2) { + prefs.browserNotifications = true; + prefs._version = 2; + } + // Migrate: v2 -> v3 adds eventTypes + if (prefs._version < 3) { + prefs.eventTypes = defaultEventTypes; + prefs._version = 3; + localStorage.setItem(storageKey, JSON.stringify(prefs)); + } + // Migrate: v3 -> v4 adds push field to all eventTypes + if (prefs._version < 4) { + if (prefs.eventTypes) { + for (const key of Object.keys(prefs.eventTypes)) { + if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) { + prefs.eventTypes[key].push = false; + } + } + } + prefs._version = 4; + localStorage.setItem(storageKey, JSON.stringify(prefs)); + } + // Merge with defaults to ensure all eventTypes exist + return { + ...defaults, + ...prefs, + eventTypes: { ...defaultEventTypes, ...prefs.eventTypes }, + }; + } + } catch (_e) { /* ignore */ } + return defaults; + } + + // Get storage key for notification prefs (device-specific) + getStorageKey() { + const isMobile = MobileDetection.getDeviceType() === 'mobile'; + return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs'; + } + + savePreferences() { + localStorage.setItem(this.getStorageKey(), JSON.stringify(this.preferences)); + } + + notify({ urgency, category, sessionId, sessionName, title, message }) { + if (!this.preferences.enabled) return; + + // Map notification categories to eventType preference keys + const categoryToEventType = { + 'hook-permission': 'permission_prompt', + 'hook-elicitation': 'elicitation_dialog', + 'hook-idle': 'idle_prompt', + 'hook-stop': 'stop', + 'session-error': 'session_error', + 'session-crash': 'session_error', + 'session-stuck': 'idle_prompt', + 'respawn-blocked': 'respawn_cycle', + 'auto-accept': 'respawn_cycle', + 'auto-clear': 'respawn_cycle', + 'ralph-complete': 'ralph_complete', + 'circuit-breaker': 'respawn_cycle', + 'exit-gate': 'ralph_complete', + 'subagent-spawn': 'subagent_spawn', + 'subagent-complete': 'subagent_complete', + 'hook-teammate-idle': 'idle_prompt', + 'hook-task-completed': 'stop', + }; + const eventTypeKey = categoryToEventType[category] || category; + + // Check per-event-type preferences first + const eventPref = this.preferences.eventTypes?.[eventTypeKey]; + let shouldBrowserNotify = false; + let shouldAudioAlert = false; + + if (eventPref) { + // Event type found - use its specific preferences + if (!eventPref.enabled) return; + shouldBrowserNotify = eventPref.browser && this.preferences.browserNotifications; + shouldAudioAlert = eventPref.audio && this.preferences.audioAlerts; + } else { + // Fall back to urgency-based muting for unknown categories + if (urgency === 'critical' && this.preferences.muteCritical) return; + if (urgency === 'warning' && this.preferences.muteWarning) return; + if (urgency === 'info' && this.preferences.muteInfo) return; + // Default browser/audio behavior based on urgency + shouldBrowserNotify = this.preferences.browserNotifications && + (urgency === 'critical' || urgency === 'warning' || !this.isTabVisible); + shouldAudioAlert = urgency === 'critical' && this.preferences.audioAlerts; + } + + // Grouping: same category+session within 5s updates count instead of new entry + const groupKey = `${category}:${sessionId || 'global'}`; + const existing = this.groupingMap.get(groupKey); + if (existing) { + existing.notification.count = (existing.notification.count || 1) + 1; + existing.notification.message = message; + existing.notification.timestamp = Date.now(); + clearTimeout(existing.timeout); + existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); + this.scheduleRender(); + return; + } + + const notification = { + id: Date.now() + '-' + Math.random().toString(36).slice(2, 7), + urgency, + category, + sessionId, + sessionName, + title, + message, + timestamp: Date.now(), + read: false, + count: 1, + }; + + // Add to log (cap at 100) + this.notifications.unshift(notification); + if (this.notifications.length > 100) this.notifications.pop(); + + // Track for grouping + const timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); + this.groupingMap.set(groupKey, { notification, timeout }); + + // Update unread + this.unreadCount++; + this.updateBadge(); + this.scheduleRender(); + + // Layer 2: Tab title (when tab unfocused) + if (!this.isTabVisible) { + this.updateTabTitle(); + } + + // Layer 3: Browser notification + if (shouldBrowserNotify) { + this.sendBrowserNotif(title, message, category, sessionId); + } + + // Layer 4: Audio alert + if (shouldAudioAlert) { + this.playAudioAlert(); + } + } + + // Layer 1: Drawer rendering + scheduleRender() { + if (this.renderScheduled) return; + this.renderScheduled = true; + requestAnimationFrame(() => { + this.renderScheduled = false; + this.renderDrawer(); + }); + } + + renderDrawer() { + const list = document.getElementById('notifList'); + const empty = document.getElementById('notifEmpty'); + if (!list || !empty) return; + + if (this.notifications.length === 0) { + list.style.display = 'none'; + empty.style.display = 'flex'; + return; + } + + list.style.display = 'block'; + empty.style.display = 'none'; + + list.innerHTML = this.notifications.map(n => { + const urgencyClass = `notif-item-${n.urgency}`; + const readClass = n.read ? '' : ' unread'; + const countLabel = n.count > 1 ? `×${n.count}` : ''; + const sessionChip = n.sessionName ? `${escapeHtml(n.sessionName)}` : ''; + return `
+
+ ${escapeHtml(n.title)}${countLabel} + ${this.relativeTime(n.timestamp)} +
+
${escapeHtml(n.message)}
+ ${sessionChip} +
`; + }).join(''); + } + + // Layer 2: Tab title with unread count + updateTabTitle() { + if (this.unreadCount > 0 && !this.isTabVisible) { + if (!this.titleFlashInterval) { + this.titleFlashInterval = setInterval(() => { + this.titleFlashState = !this.titleFlashState; + document.title = this.titleFlashState + ? `\u26A0\uFE0F (${this.unreadCount}) Codeman` + : this.originalTitle; + }, TITLE_FLASH_INTERVAL_MS); + // Set immediately + document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`; + } + } + } + + stopTitleFlash() { + if (this.titleFlashInterval) { + clearInterval(this.titleFlashInterval); + this.titleFlashInterval = null; + this.titleFlashState = false; + document.title = this.originalTitle; + } + } + + // Layer 3: Web Notification API + sendBrowserNotif(title, body, tag, sessionId) { + if (!this.preferences.browserNotifications) return; + if (typeof Notification === 'undefined') return; + if (Notification.permission === 'default') { + // Auto-request on first notification attempt + Notification.requestPermission().then(result => { + if (result === 'granted') { + // Re-send this notification now that we have permission + this.sendBrowserNotif(title, body, tag, sessionId); + } + }); + return; + } + if (Notification.permission !== 'granted') return; + + // Rate limit: max 1 per 3 seconds + const now = Date.now(); + if (now - this.lastBrowserNotifTime < 3000) return; + this.lastBrowserNotifTime = now; + + const notif = new Notification(`Codeman: ${title}`, { + body, + tag, // Groups same-tag notifications + icon: '/favicon.ico', + silent: true, // We handle audio ourselves + }); + + notif.onclick = () => { + window.focus(); + if (sessionId && this.app.sessions.has(sessionId)) { + this.app.selectSession(sessionId); + } + notif.close(); + }; + + // Auto-close after 8s + setTimeout(() => notif.close(), 8000); + } + + async requestPermission() { + if (typeof Notification === 'undefined') { + this.app.showToast('Browser notifications not supported', 'warning'); + return; + } + const result = await Notification.requestPermission(); + const statusEl = document.getElementById('notifPermissionStatus'); + if (statusEl) statusEl.textContent = `Status: ${result}`; + if (result === 'granted') { + this.preferences.browserNotifications = true; + this.savePreferences(); + this.app.showToast('Notifications enabled', 'success'); + } else { + this.app.showToast(`Permission ${result}`, 'warning'); + } + } + + // Layer 4: Audio alert via Web Audio API + playAudioAlert() { + try { + if (!this.audioCtx) { + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } + if (this.audioCtx.state === 'suspended') { + this.audioCtx.resume(); + } + const ctx = this.audioCtx; + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + oscillator.connect(gain); + gain.connect(ctx.destination); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(660, ctx.currentTime); + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15); + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + 0.15); + } catch (_e) { /* Audio not available */ } + } + + // UI interactions + toggleDrawer() { + const drawer = document.getElementById('notifDrawer'); + if (!drawer) return; + this.isDrawerOpen = !this.isDrawerOpen; + drawer.classList.toggle('open', this.isDrawerOpen); + if (this.isDrawerOpen) { + this.renderDrawer(); + } + } + + clickNotification(notifId) { + const notif = this.notifications.find(n => n.id === notifId); + if (!notif) return; + + // Mark as read + if (!notif.read) { + notif.read = true; + this.unreadCount = Math.max(0, this.unreadCount - 1); + this.updateBadge(); + } + + // Switch to session if available + if (notif.sessionId && this.app.sessions.has(notif.sessionId)) { + this.app.selectSession(notif.sessionId); + this.toggleDrawer(); + } + + this.scheduleRender(); + } + + markAllRead() { + this.notifications.forEach(n => { n.read = true; }); + this.unreadCount = 0; + this.updateBadge(); + this.stopTitleFlash(); + this.scheduleRender(); + } + + clearAll() { + this.notifications = []; + this.unreadCount = 0; + this.updateBadge(); + this.stopTitleFlash(); + this.scheduleRender(); + } + + updateBadge() { + const badge = document.getElementById('notifBadge'); + if (!badge) return; + if (this.unreadCount > 0) { + badge.style.display = 'flex'; + badge.textContent = this.unreadCount > 99 ? '99+' : String(this.unreadCount); + } else { + badge.style.display = 'none'; + } + } + + onTabVisible() { + this.stopTitleFlash(); + // If drawer is open, mark all as read + if (this.isDrawerOpen) { + this.markAllRead(); + } + // Re-fit terminal and send resize to PTY so this client's dimensions win. + // Fixes broken layout when switching between desktop and mobile on the same session. + if (this.app?.fitAddon && this.app?.activeSessionId) { + this.app.fitAddon.fit(); + this.app.sendResize(this.app.activeSessionId); + } + } + + // Utilities + relativeTime(ts) { + const diff = Math.floor((Date.now() - ts) / 1000); + if (diff < 60) return 'now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; + } +} diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index 9f20980c..6c3d4118 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -361,7 +361,7 @@ Object.assign(CodemanApp.prototype, { prompt += 'Output `BLOCKED` with explanation'; // Show preview with highlighting (escape first, then apply formatting) - const escapedPrompt = this.escapeHtml(prompt); + const escapedPrompt = escapeHtml(prompt); const highlightedPrompt = escapedPrompt .replace(/<promise>/g, '<promise>') .replace(/<\/promise>/g, '</promise>') @@ -724,7 +724,7 @@ Object.assign(CodemanApp.prototype, { ${item.priority ? `${item.priority}` : ''} - ${this.escapeHtml(item.content)} + ${escapeHtml(item.content)} `; fragment.appendChild(row); }); @@ -847,7 +847,7 @@ Object.assign(CodemanApp.prototype, {
${typeIcons[agentType] || '🤖'} - ${typeLabels[agentType] || this.escapeHtml(agentType)} + ${typeLabels[agentType] || escapeHtml(agentType)} ${model}
diff --git a/src/web/public/subagent-windows.js b/src/web/public/subagent-windows.js new file mode 100644 index 00000000..23d60ecf --- /dev/null +++ b/src/web/public/subagent-windows.js @@ -0,0 +1,1032 @@ +// Codeman — Subagent window management for CodemanApp +// Loaded after app.js (needs CodemanApp class defined) + +Object.assign(CodemanApp.prototype, { + // Render subagent badge with dropdown for minimized agents on a tab + renderSubagentTabBadge(sessionId, minimizedAgents) { + if (!minimizedAgents || minimizedAgents.size === 0) return ''; + + const agentItems = []; + for (const agentId of minimizedAgents) { + const agent = this.subagents.get(agentId); + const displayName = agent?.description || agentId.substring(0, 12); + const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName; + const statusClass = agent?.status || 'idle'; + agentItems.push(` +
+ + ${escapeHtml(truncatedName)} + × +
+ `); + } + + // Compact badge - shows on hover, click to pin open + const count = minimizedAgents.size; + const label = count === 1 ? 'AGENT' : `AGENTS (${count})`; + return ` + + ${label} +
+ ${agentItems.join('')} +
+
+ `; + }, + + // Restore a minimized subagent window + restoreMinimizedSubagent(agentId, sessionId) { + // Remove from minimized set + const minimizedAgents = this.minimizedSubagents.get(sessionId); + if (minimizedAgents) { + minimizedAgents.delete(agentId); + if (minimizedAgents.size === 0) { + this.minimizedSubagents.delete(sessionId); + } + } + + // Restore the window + this.restoreSubagentWindow(agentId); + + // Re-render tabs to update badge + this.renderSessionTabs(); + + // Persist the state change + this.saveSubagentWindowStates(); + }, + + // Permanently close a minimized subagent (remove from DOM and minimized set) + permanentlyCloseMinimizedSubagent(agentId, sessionId) { + // Remove from minimized set + const minimizedAgents = this.minimizedSubagents.get(sessionId); + if (minimizedAgents) { + minimizedAgents.delete(agentId); + if (minimizedAgents.size === 0) { + this.minimizedSubagents.delete(sessionId); + } + } + + // Force close the window (removes from DOM) + this.forceCloseSubagentWindow(agentId); + + // Re-render tabs to update badge + this.renderSessionTabs(); + this.updateConnectionLines(); + + // Persist the state change + this.saveSubagentWindowStates(); + }, + + // ========== Subagent Window State Persistence ========== + + /** + * Save subagent window states (minimized/open) to server for cross-browser persistence. + * Called when a window is minimized, restored, or auto-minimized on completion. + */ + async saveSubagentWindowStates() { + // Build state object: which agents are minimized per session + const minimizedState = {}; + for (const [sessionId, agentIds] of this.minimizedSubagents) { + minimizedState[sessionId] = Array.from(agentIds); + } + + // Also track which windows are open (not minimized) + const openWindows = []; + for (const [agentId, windowData] of this.subagentWindows) { + if (!windowData.minimized) { + openWindows.push({ + agentId, + position: windowData.position || null + }); + } + } + + const windowStates = { minimized: minimizedState, open: openWindows }; + + // Save to localStorage for quick restore + localStorage.setItem('codeman-subagent-window-states', JSON.stringify(windowStates)); + + // Save to server for cross-browser persistence + try { + await fetch('/api/subagent-window-states', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(windowStates) + }); + } catch (err) { + console.error('Failed to save subagent window states to server:', err); + } + }, + + /** + * Restore subagent window states after loading subagents. + * Opens windows that were open before, keeps minimized ones minimized. + * IMPORTANT: Parent associations are loaded from subagentParentMap BEFORE this is called. + */ + async restoreSubagentWindowStates() { + const states = await this.loadSubagentWindowStates(); + + // Restore minimized state using the PERSISTENT parent map + // Skip old agents from previous runs to avoid confusion + const cutoffTime = Date.now() - 10 * 60 * 1000; // 10 minutes + for (const [savedSessionId, agentIds] of Object.entries(states.minimized || {})) { + if (Array.isArray(agentIds) && agentIds.length > 0) { + for (const agentId of agentIds) { + const agent = this.subagents.get(agentId); + if (!agent) continue; // Agent no longer exists + + // Skip completed or old agents + const agentStartTime = agent.startedAt || 0; + if (agent.status === 'completed' || agentStartTime < cutoffTime) continue; + + // Use the PERSISTENT parent map (THE source of truth) + // Fall back to saved sessionId only if it exists in current sessions + const parentFromMap = this.subagentParentMap.get(agentId); + const correctSessionId = parentFromMap || + (this.sessions.has(savedSessionId) ? savedSessionId : null); + + if (correctSessionId) { + // Ensure the parent map has this association + if (!parentFromMap && this.sessions.has(savedSessionId)) { + this.setAgentParentSessionId(agentId, savedSessionId); + } + + if (!this.minimizedSubagents.has(correctSessionId)) { + this.minimizedSubagents.set(correctSessionId, new Set()); + } + this.minimizedSubagents.get(correctSessionId).add(agentId); + } + } + } + } + + // Restore open windows (for recent, non-completed agents only) + const now = Date.now(); + const maxAgeMs = 10 * 60 * 1000; // 10 minutes - don't restore windows for old agents + for (const { agentId, position } of (states.open || [])) { + const agent = this.subagents.get(agentId); + // Only restore window if agent exists, is recent, and is still active/idle + const agentAge = agent?.startedAt ? now - agent.startedAt : Infinity; + if (agent && agent.status !== 'completed' && agentAge < maxAgeMs) { + this.openSubagentWindow(agentId); + // Restore position if saved (with viewport bounds check) + if (position) { + const windowData = this.subagentWindows.get(agentId); + if (windowData && windowData.element) { + // Parse position values and clamp to viewport + let left = parseInt(position.left, 10) || 50; + let top = parseInt(position.top, 10) || 120; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const windowWidth = 420; + const windowHeight = 350; + left = Math.max(10, Math.min(left, viewportWidth - windowWidth - 10)); + top = Math.max(10, Math.min(top, viewportHeight - windowHeight - 10)); + windowData.element.style.left = `${left}px`; + windowData.element.style.top = `${top}px`; + windowData.position = { left: `${left}px`, top: `${top}px` }; + } + } + } + } + + this.renderSessionTabs(); // Update tab badges + this.saveSubagentWindowStates(); // Persist corrected mappings + + // Update connection lines after all windows are restored (use rAF to ensure DOM is ready) + requestAnimationFrame(() => { + this.updateConnectionLines(); + }); + }, + + // ========== Subagent Connection Lines ========== + // + // Connection lines are drawn from agent windows to their parent TABs. + // The parent TAB is determined by the PERSISTENT subagentParentMap. + // This map stores agentId -> sessionId, where sessionId is the tab's data-id. + + updateConnectionLines() { + // Coalesce multiple calls — uses background scheduler priority to avoid + // competing with terminal writes for main thread time + if (!this._connectionLinesScheduled) { + this._connectionLinesScheduled = true; + scheduleBackground(() => { + this._connectionLinesScheduled = false; + this._updateConnectionLinesImmediate(); + }); + } + }, + + _updateConnectionLinesImmediate() { + const svg = document.getElementById('connectionLines'); + if (!svg) return; + + // Check if Ralph wizard modal is open + const wizardModal = document.getElementById('ralphWizardModal'); + const wizardOpen = wizardModal?.classList.contains('active'); + const wizardContent = wizardOpen ? wizardModal.querySelector('.modal-content') : null; + + // Collect visible regular subagent windows + const visibleSubagentWindows = []; + for (const [agentId, windowInfo] of this.subagentWindows) { + if (windowInfo.minimized || windowInfo.hidden) continue; + const win = windowInfo.element; + if (!win) continue; + visibleSubagentWindows.push({ agentId, windowInfo, win }); + } + + // Get plan subagent windows as array for distribution + const planSubagentArray = Array.from(this.planSubagents.entries()) + .filter(([, data]) => data.element) + .map(([id, data]) => ({ id, ...data })); + + // === PHASE 1: Batch all layout reads (getBoundingClientRect) === + // Reading layout properties forces the browser to calculate layout. + // By batching all reads before any writes, we avoid repeated forced reflows. + const rects = new Map(); + + // Read all subagent window rects + for (const { agentId, win } of visibleSubagentWindows) { + rects.set('sub:' + agentId, win.getBoundingClientRect()); + } + + // Read all plan subagent rects + for (const planAgent of planSubagentArray) { + rects.set('plan:' + planAgent.id, planAgent.element.getBoundingClientRect()); + } + + // Read wizard rect (if open) + let wizardRect = null; + if (wizardOpen && wizardContent) { + wizardRect = wizardContent.getBoundingClientRect(); + } + + // Read tab rects for normal mode (only tabs that are actually needed) + if (!wizardOpen) { + for (const { agentId } of visibleSubagentWindows) { + const parentSessionId = this.subagentParentMap.get(agentId); + if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue; + const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`); + if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect()); + } + } + + // Read plan window rects for wizard-to-plan lines + if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) { + for (const [agentId, windowData] of this.planSubagents) { + if (!windowData.element) continue; + const key = 'planwin:' + agentId; + if (!rects.has(key)) rects.set(key, windowData.element.getBoundingClientRect()); + } + } + + // === PHASE 2: DOM writes using cached rects (no more layout reads) === + svg.innerHTML = ''; + + for (const { agentId } of visibleSubagentWindows) { + const winRect = rects.get('sub:' + agentId); + + // If wizard is open with plan subagents, connect regular subagents to plan subagent windows + if (wizardOpen && wizardContent && planSubagentArray.length > 0) { + // Find the nearest plan subagent window to connect to + let nearestPlanAgent = null; + let nearestDistance = Infinity; + + for (const planAgent of planSubagentArray) { + const planRect = rects.get('plan:' + planAgent.id); + const planCenterX = planRect.left + planRect.width / 2; + const planCenterY = planRect.top + planRect.height / 2; + const winCenterX = winRect.left + winRect.width / 2; + const winCenterY = winRect.top + winRect.height / 2; + const distance = Math.hypot(planCenterX - winCenterX, planCenterY - winCenterY); + + if (distance < nearestDistance) { + nearestDistance = distance; + nearestPlanAgent = planAgent; + } + } + + if (nearestPlanAgent) { + const planRect = rects.get('plan:' + nearestPlanAgent.id); + + // Draw line from plan subagent window to regular subagent window + let x1, y1, x2, y2; + const planCenterX = planRect.left + planRect.width / 2; + const winCenterX = winRect.left + winRect.width / 2; + + if (winCenterX < planCenterX) { + x1 = planRect.left; + y1 = planRect.top + planRect.height / 2; + x2 = winRect.right; + y2 = winRect.top + winRect.height / 2; + } else { + x1 = planRect.right; + y1 = planRect.top + planRect.height / 2; + x2 = winRect.left; + y2 = winRect.top + winRect.height / 2; + } + + const midX = (x1 + x2) / 2; + const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + line.setAttribute('d', path); + line.setAttribute('class', 'connection-line plan-to-subagent-line'); + line.setAttribute('data-agent-id', agentId); + line.setAttribute('data-plan-agent-id', nearestPlanAgent.id); + svg.appendChild(line); + } + } else if (wizardOpen && wizardContent) { + // Wizard open but no plan subagents - connect directly to wizard + const winCenterX = winRect.left + winRect.width / 2; + const wizardCenterX = wizardRect.left + wizardRect.width / 2; + + let x1, y1, x2, y2; + + if (winCenterX < wizardCenterX) { + x1 = wizardRect.left; + y1 = wizardRect.top + wizardRect.height / 2; + x2 = winRect.right; + y2 = winRect.top + winRect.height / 2; + } else { + x1 = wizardRect.right; + y1 = wizardRect.top + wizardRect.height / 2; + x2 = winRect.left; + y2 = winRect.top + winRect.height / 2; + } + + const midX = (x1 + x2) / 2; + const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + line.setAttribute('d', path); + line.setAttribute('class', 'connection-line wizard-connection'); + line.setAttribute('data-agent-id', agentId); + svg.appendChild(line); + } else { + // NORMAL MODE: Connect agent window to its parent TAB + // Use the PERSISTENT subagentParentMap as the ONLY source of truth + const parentSessionId = this.subagentParentMap.get(agentId); + + if (!parentSessionId) { + // No parent known yet - skip this agent + continue; + } + + const tabRect = rects.get('tab:' + parentSessionId); + if (!tabRect) { + // Tab not in DOM (might be scrolled out or session closed) + continue; + } + + // Draw curved line from TAB bottom-center to window top-center + const x1 = tabRect.left + tabRect.width / 2; + const y1 = tabRect.bottom; + const x2 = winRect.left + winRect.width / 2; + const y2 = winRect.top; + + // Bezier curve control points for smooth curve + const midY = (y1 + y2) / 2; + const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + line.setAttribute('d', path); + line.setAttribute('class', 'connection-line'); + line.setAttribute('data-agent-id', agentId); + line.setAttribute('data-parent-tab', parentSessionId); + svg.appendChild(line); + } + } + + // Draw lines from wizard to plan subagent windows (Opus agents during plan generation) + // Skip if agents are minimized to tab + if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) { + for (const [agentId] of this.planSubagents) { + const winRect = rects.get('planwin:' + agentId); + if (!winRect) continue; + + // Determine which side of wizard the window is on + const winCenterX = winRect.left + winRect.width / 2; + const wizardCenterX = wizardRect.left + wizardRect.width / 2; + + let x1, y1, x2, y2; + + if (winCenterX < wizardCenterX) { + x1 = wizardRect.left; + y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50); + x2 = winRect.right; + y2 = winRect.top + winRect.height / 2; + } else { + x1 = wizardRect.right; + y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50); + x2 = winRect.left; + y2 = winRect.top + winRect.height / 2; + } + + const midX = (x1 + x2) / 2; + const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + line.setAttribute('d', path); + line.setAttribute('class', 'connection-line wizard-connection plan-subagent-line'); + line.setAttribute('data-plan-agent-id', agentId); + svg.appendChild(line); + } + } + }, + + // ========== Subagent Floating Windows ========== + + openSubagentWindow(agentId) { + // If window already exists, focus it + if (this.subagentWindows.has(agentId)) { + const existing = this.subagentWindows.get(agentId); + const agent = this.subagents.get(agentId); + const settings = this.loadAppSettingsFromStorage(); + const activeTabOnly = settings.subagentActiveTabOnly ?? true; + + // If window is hidden (different tab) and activeTabOnly is enabled, switch to parent tab + if (existing.hidden && agent?.parentSessionId && activeTabOnly) { + this.selectSession(agent.parentSessionId); + return; + } + + // If not activeTabOnly mode, just show the window + if (existing.hidden && !activeTabOnly) { + existing.element.style.display = 'flex'; + existing.hidden = false; + } + + existing.element.style.zIndex = ++this.subagentWindowZIndex; + if (existing.minimized) { + this.restoreSubagentWindow(agentId); + } + return; + } + + const agent = this.subagents.get(agentId); + if (!agent) return; + + // Only open windows for agents that belong to a Codeman-managed session tab. + // Agents from external Claude sessions (not tracked by Codeman) should not pop up. + if (agent.sessionId) { + const hasMatchingTab = Array.from(this.sessions.values()).some(s => s.claudeSessionId === agent.sessionId); + if (!hasMatchingTab) return; + } + + // Calculate final position - grid layout to avoid overlaps + const windowCount = this.subagentWindows.size; + const isMobile = MobileDetection.getDeviceType() === 'mobile'; + const mobileCardHeight = 110; + const mobileCardGap = 4; + const windowWidth = isMobile ? window.innerWidth : 420; + const windowHeight = isMobile ? mobileCardHeight : 350; + const gap = 20; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let finalX = 0; + let finalY = 0; + + if (isMobile) { + // Mobile: stack compact cards. Count visible (non-minimized) windows. + let visibleCount = 0; + for (const [, data] of this.subagentWindows) { + if (!data.minimized && !data.hidden) visibleCount++; + } + finalX = 4; + const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + if (keyboardUp) { + // Keyboard visible: stack from bottom above toolbar + const toolbarHeight = 40; + const bottomOffset = toolbarHeight + visibleCount * (mobileCardHeight + mobileCardGap); + finalY = viewportHeight - bottomOffset - mobileCardHeight; + } else { + // Keyboard hidden: stack from top below header with spacing + const headerHeight = document.querySelector('.header')?.offsetHeight || 36; + const topStart = headerHeight + 8; + finalY = topStart + visibleCount * (mobileCardHeight + mobileCardGap); + } + } else { + // Check if Ralph wizard modal is open - if so, position windows on the sides + const wizardModal = document.getElementById('ralphWizardModal'); + const wizardOpen = wizardModal?.classList.contains('active'); + + let startX, startY, maxCols; + + if (wizardOpen) { + // Wizard is ~720px wide, centered. Position windows on left/right sides + const wizardWidth = 720; + const centerX = viewportWidth / 2; + const wizardLeft = centerX - wizardWidth / 2; + const wizardRight = centerX + wizardWidth / 2; + + // Alternate between left and right sides of the wizard + const leftSideSpace = wizardLeft - 20; + const rightSideSpace = viewportWidth - wizardRight - 20; + + if (windowCount % 2 === 0 && rightSideSpace >= windowWidth) { + // Even windows go to the right + startX = wizardRight + 20; + maxCols = Math.floor(rightSideSpace / (windowWidth + gap)) || 1; + } else if (leftSideSpace >= windowWidth) { + // Odd windows go to the left + startX = Math.max(10, wizardLeft - windowWidth - 20); + maxCols = 1; // Usually only room for 1 column on left + } else { + // Not enough side space, use right side + startX = wizardRight + 20; + maxCols = 1; + } + startY = 80; // Start higher when wizard is open + } else { + // Normal positioning + startX = 50; + startY = 120; + maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1; + } + + const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1; + const col = windowCount % maxCols; + const row = Math.floor(windowCount / maxCols) % maxRows; // Wrap rows to stay in viewport + finalX = startX + col * (windowWidth + gap); + finalY = startY + row * (windowHeight + gap); + + // Ensure window stays within viewport bounds + finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); + finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); + } + + // Get parent session from PERSISTENT map (THE source of truth for tab connections) + const parentSessionId = this.subagentParentMap.get(agentId) || agent.parentSessionId; + let parentSessionName = null; + + if (parentSessionId) { + const parentSession = this.sessions.get(parentSessionId); + if (parentSession) { + parentSessionName = this.getSessionName(parentSession); + // Ensure the agent object is also updated for consistency + if (!agent.parentSessionId) { + agent.parentSessionId = parentSessionId; + agent.parentSessionName = parentSessionName; + this.subagents.set(agentId, agent); + } + } + } + + // Get parent TAB element for spawn animation + const parentTab = parentSessionId + ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) + : null; + + // Create window element + const win = document.createElement('div'); + win.className = 'subagent-window'; + win.id = `subagent-window-${agentId}`; + win.style.zIndex = ++this.subagentWindowZIndex; + + // Build parent header if we have parent info + const parentHeader = parentSessionId && parentSessionName + ? `
+ from + ${escapeHtml(parentSessionName)} +
` + : ''; + + const teammateInfo = this.getTeammateInfo(agent); + const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7)); + const maxTitleLen = isMobile ? 30 : 50; + const truncatedTitle = windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle; + const modelBadge = agent.modelShort + ? `${agent.modelShort}` + : ''; + win.innerHTML = ` +
+
+ 🤖 + ${escapeHtml(truncatedTitle)} + ${modelBadge} + ${agent.status} +
+
+ +
+
+ ${parentHeader} +
+
Loading activity...
+
+ `; + + // If we have a parent tab, start window at tab position for spawn animation + if (isMobile) { + // Mobile: position using top (keyboard-aware positioning calculated above) + win.style.top = `${finalY}px`; + win.style.bottom = 'auto'; + } else if (parentTab) { + const tabRect = parentTab.getBoundingClientRect(); + win.style.left = `${tabRect.left}px`; + win.style.top = `${tabRect.bottom}px`; + win.style.transform = 'scale(0.3)'; + win.style.opacity = '0'; + win.classList.add('spawning'); + } else { + // No parent tab, just position normally (desktop/tablet) + win.style.left = `${finalX}px`; + win.style.top = `${finalY}px`; + } + + document.body.appendChild(win); + + // Make draggable (returns listener refs for cleanup) + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); + + // Check if this window should be visible based on settings + // Use the PERSISTENT parent map for accurate tab-based visibility + const settings = this.loadAppSettingsFromStorage(); + const activeTabOnly = settings.subagentActiveTabOnly ?? true; + let shouldHide = false; + if (activeTabOnly) { + const storedParent = this.subagentParentMap.get(agentId); + const hasKnownParent = storedParent || agent.parentSessionId; + const parentId = storedParent || agent.parentSessionId; + const isForActiveSession = !hasKnownParent || parentId === this.activeSessionId; + shouldHide = !isForActiveSession; + } + + // Store reference (including drag listeners for cleanup) + this.subagentWindows.set(agentId, { + element: win, + minimized: false, + hidden: shouldHide, + dragListeners, // Store for cleanup to prevent memory leaks + }); + + // Hide window if not for active session + if (shouldHide) { + win.style.display = 'none'; + } + + // Render content — check if this teammate has a tmux pane + const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null; + if (paneInfo) { + this.initTeammateTerminal(agentId, paneInfo, win); + } else { + this.renderSubagentWindowContent(agentId); + } + + // Focus on click + win.addEventListener('mousedown', () => { + win.style.zIndex = ++this.subagentWindowZIndex; + }); + + // Update connection lines when window is resized + const resizeObserver = new ResizeObserver(() => { + this.updateConnectionLines(); + }); + resizeObserver.observe(win); + + // Store observer for cleanup + this.subagentWindows.get(agentId).resizeObserver = resizeObserver; + + // Animate to final position if spawning from tab (desktop only) + if (parentTab && !isMobile) { + requestAnimationFrame(() => { + win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; + win.style.left = `${finalX}px`; + win.style.top = `${finalY}px`; + win.style.transform = 'scale(1)'; + win.style.opacity = '1'; + + // Clean up after animation + setTimeout(() => { + win.style.transition = ''; + win.classList.remove('spawning'); + this.updateConnectionLines(); + }, 400); + }); + } else { + // No animation (mobile uses CSS positioning), just update connection lines + this.updateConnectionLines(); + } + + // Persist the state change (new window opened) + this.saveSubagentWindowStates(); + }, + + closeSubagentWindow(agentId) { + const windowData = this.subagentWindows.get(agentId); + if (!windowData) return; + + const agent = this.subagents.get(agentId); + + // Get parent from PERSISTENT map (THE source of truth) + // Fall back to agent's parentSessionId, then to active session + const storedParent = this.subagentParentMap.get(agentId); + let parentSessionId = storedParent || agent?.parentSessionId || this.activeSessionId; + + // If we don't have a stored parent yet, store it now + if (!storedParent && parentSessionId && this.sessions.has(parentSessionId)) { + this.setAgentParentSessionId(agentId, parentSessionId); + } + + // Always minimize to tab + windowData.element.style.display = 'none'; + windowData.minimized = true; + + // Track minimized agent for the session (use the TAB's session ID) + if (parentSessionId) { + if (!this.minimizedSubagents.has(parentSessionId)) { + this.minimizedSubagents.set(parentSessionId, new Set()); + } + this.minimizedSubagents.get(parentSessionId).add(agentId); + + // Update tab badge to show minimized agents + this.renderSessionTabs(); + } + + // Persist the state change + this.saveSubagentWindowStates(); + this.updateConnectionLines(); + // Restack remaining visible mobile windows to fill the gap + this.relayoutMobileSubagentWindows(); + }, + + /** Reposition all visible mobile subagent windows (called on keyboard show/hide). */ + relayoutMobileSubagentWindows() { + if (MobileDetection.getDeviceType() !== 'mobile') return; + const mobileCardHeight = 110; + const mobileCardGap = 4; + const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + let idx = 0; + for (const [, data] of this.subagentWindows) { + if (data.minimized || data.hidden) continue; + const el = data.element; + // Reset left to proper position (drag may have set an arbitrary value) + el.style.left = '4px'; + if (keyboardUp) { + // Stack from bottom above toolbar + const bottomPx = 40 + idx * (mobileCardHeight + mobileCardGap); + el.style.bottom = `${bottomPx}px`; + el.style.top = 'auto'; + } else { + // Stack from top below header + const headerHeight = document.querySelector('.header')?.offsetHeight || 36; + const topPx = headerHeight + 8 + idx * (mobileCardHeight + mobileCardGap); + el.style.top = `${topPx}px`; + el.style.bottom = 'auto'; + } + idx++; + } + }, + + // Clean up ALL floating windows (called during handleInit to prevent memory leaks on reconnect) + cleanupAllFloatingWindows() { + // Clean up all subagent windows with their ResizeObservers and drag listeners + for (const [agentId, windowData] of this.subagentWindows) { + if (windowData.resizeObserver) { + windowData.resizeObserver.disconnect(); + } + if (windowData.dragListeners) { + document.removeEventListener('mousemove', windowData.dragListeners.move); + document.removeEventListener('mouseup', windowData.dragListeners.up); + if (windowData.dragListeners.touchMove) { + document.removeEventListener('touchmove', windowData.dragListeners.touchMove); + document.removeEventListener('touchend', windowData.dragListeners.up); + document.removeEventListener('touchcancel', windowData.dragListeners.up); + } + } + windowData.element.remove(); + } + this.subagentWindows.clear(); + + // Clean up all teammate terminals + for (const [, termData] of this.teammateTerminals) { + if (termData.resizeObserver) termData.resizeObserver.disconnect(); + if (termData.terminal) { + try { termData.terminal.dispose(); } catch {} + } + } + this.teammateTerminals.clear(); + this.teammatePanesByName.clear(); + + // Clean up all log viewer windows with their EventSources and drag listeners + for (const [windowId, data] of this.logViewerWindows) { + if (data.eventSource) { + data.eventSource.close(); + } + if (data.dragListeners) { + document.removeEventListener('mousemove', data.dragListeners.move); + document.removeEventListener('mouseup', data.dragListeners.up); + if (data.dragListeners.touchMove) { + document.removeEventListener('touchmove', data.dragListeners.touchMove); + document.removeEventListener('touchend', data.dragListeners.up); + document.removeEventListener('touchcancel', data.dragListeners.up); + } + } + data.element.remove(); + } + this.logViewerWindows.clear(); + + // Clean up plan subagent windows (wizard agents) + if (this.planSubagents) { + for (const [agentId, windowData] of this.planSubagents) { + if (windowData.dragListeners) { + document.removeEventListener('mousemove', windowData.dragListeners.move); + document.removeEventListener('mouseup', windowData.dragListeners.up); + } + if (windowData.element) { + windowData.element.remove(); + } + } + this.planSubagents.clear(); + } + + // Clean up all image popup windows with their drag listeners + for (const [imageId, popupData] of this.imagePopups) { + if (popupData.dragListeners) { + document.removeEventListener('mousemove', popupData.dragListeners.move); + document.removeEventListener('mouseup', popupData.dragListeners.up); + if (popupData.dragListeners.touchMove) { + document.removeEventListener('touchmove', popupData.dragListeners.touchMove); + document.removeEventListener('touchend', popupData.dragListeners.up); + document.removeEventListener('touchcancel', popupData.dragListeners.up); + } + } + popupData.element.remove(); + } + this.imagePopups.clear(); + + // Clear orphaned plan generation state + this.activePlanOrchestratorId = null; + this._planProgressHandler = null; + this.planGenerationStopped = true; + if (this.planGenerationAbortController) { + this.planGenerationAbortController.abort(); + this.planGenerationAbortController = null; + } + + // Clean up wizard-specific timers (leak fix: not cleared on SSE reconnect) + if (this.wizardMinimizedTimer) { + clearInterval(this.wizardMinimizedTimer); + this.wizardMinimizedTimer = null; + } + + // Clean up wizard drag listeners (leak fix: document-level handlers) + this.cleanupWizardDragging(); + + // Deactivate focus trap if wizard was open (leak fix: keydown listener) + if (this.activeFocusTrap) { + this.activeFocusTrap.deactivate(); + this.activeFocusTrap = null; + } + + // Clean up team tasks panel drag listeners + if (this.teamTasksDragListeners) { + document.removeEventListener('mousemove', this.teamTasksDragListeners.move); + document.removeEventListener('mouseup', this.teamTasksDragListeners.up); + if (this.teamTasksDragListeners.touchMove) { + document.removeEventListener('touchmove', this.teamTasksDragListeners.touchMove); + document.removeEventListener('touchend', this.teamTasksDragListeners.up); + document.removeEventListener('touchcancel', this.teamTasksDragListeners.up); + } + this.teamTasksDragListeners = null; + } + + // Clear minimized agents tracking + this.minimizedSubagents.clear(); + + // Update monitor panel + this.renderMonitorPlanAgents(); + + // Update connection lines (should be empty now) + this.updateConnectionLines(); + }, + + restoreSubagentWindow(agentId) { + const windowData = this.subagentWindows.get(agentId); + const agent = this.subagents.get(agentId); + + // If window doesn't exist but agent does, recreate it + if (!windowData && agent) { + this.openSubagentWindow(agentId); + return; + } + + if (windowData) { + const settings = this.loadAppSettingsFromStorage(); + const activeTabOnly = settings.subagentActiveTabOnly ?? true; + + // Get parent from PERSISTENT map (THE source of truth) + const storedParent = this.subagentParentMap.get(agentId); + const parentSessionId = storedParent || agent?.parentSessionId; + + // Determine if we should show the window + let shouldShow = true; + if (activeTabOnly) { + // Only restore if the window belongs to the active session (or has no parent) + shouldShow = !parentSessionId || parentSessionId === this.activeSessionId; + } + + if (shouldShow) { + windowData.element.style.display = 'flex'; + windowData.element.style.zIndex = ++this.subagentWindowZIndex; + windowData.hidden = false; + } + windowData.minimized = false; + this.updateConnectionLines(); + // Restack all visible mobile windows so restored ones don't overlap + this.relayoutMobileSubagentWindows(); + } + }, + + // Returns drag listener references for cleanup (prevents memory leaks) + makeWindowDraggable(win, handle) { + let isDragging = false; + let startX, startY, startLeft, startTop; + let dragUpdateScheduled = false; + + const startDrag = (clientX, clientY) => { + isDragging = true; + startX = clientX; + startY = clientY; + startLeft = parseInt(win.style.left) || win.getBoundingClientRect().left; + startTop = parseInt(win.style.top) || win.getBoundingClientRect().top; + // On drag start, switch from bottom-positioned to top-positioned so left/top work + win.style.bottom = 'auto'; + }; + + const moveDrag = (clientX, clientY) => { + if (!isDragging) return; + const dx = clientX - startX; + const dy = clientY - startY; + // Constrain to viewport bounds + const winWidth = win.offsetWidth || 420; + const winHeight = win.offsetHeight || 350; + const maxX = window.innerWidth - winWidth - 4; + const maxY = window.innerHeight - winHeight - 4; + const newLeft = Math.max(4, Math.min(startLeft + dx, maxX)); + const newTop = Math.max(4, Math.min(startTop + dy, maxY)); + win.style.left = `${newLeft}px`; + win.style.top = `${newTop}px`; + // Throttle connection line updates during drag + if (!dragUpdateScheduled) { + dragUpdateScheduled = true; + requestAnimationFrame(() => { + this.updateConnectionLines(); + dragUpdateScheduled = false; + }); + } + }; + + const endDrag = () => { + if (isDragging) { + isDragging = false; + // Save position after drag ends + this.saveSubagentWindowStates(); + } + }; + + // Mouse events + handle.addEventListener('mousedown', (e) => { + if (e.target.tagName === 'BUTTON') return; + startDrag(e.clientX, e.clientY); + e.preventDefault(); + }); + + // Touch events + handle.addEventListener('touchstart', (e) => { + if (e.target.tagName === 'BUTTON') return; + const touch = e.touches[0]; + startDrag(touch.clientX, touch.clientY); + }, { passive: true }); + + // Store references to document-level listeners so they can be removed on window close + const moveListener = (e) => { + moveDrag(e.clientX, e.clientY); + }; + + const touchMoveListener = (e) => { + if (!isDragging) return; + e.preventDefault(); // Prevent page scroll while dragging + const touch = e.touches[0]; + moveDrag(touch.clientX, touch.clientY); + }; + + const upListener = () => { + endDrag(); + }; + + document.addEventListener('mousemove', moveListener); + document.addEventListener('mouseup', upListener); + document.addEventListener('touchmove', touchMoveListener, { passive: false }); + document.addEventListener('touchend', upListener); + document.addEventListener('touchcancel', upListener); + + // Return listener references for cleanup + return { move: moveListener, up: upListener, touchMove: touchMoveListener }; + }, +}); diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js new file mode 100644 index 00000000..f931739b --- /dev/null +++ b/src/web/public/voice-input.js @@ -0,0 +1,853 @@ +// Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback +// Loaded after mobile-handlers.js, before app.js + +// ============================================================================ +// Voice Input (Deepgram Nova-3 + Web Speech API fallback) +// ============================================================================ + +/** + * DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API. + * Direct browser-to-Deepgram connection (no server proxy). + * Uses MediaRecorder to capture audio and streams via WebSocket. + */ +const DeepgramProvider = { + _ws: null, + _mediaRecorder: null, + _stream: null, + _silenceTimeout: null, + _keepAliveInterval: null, + _onResult: null, + _onError: null, + _onEnd: null, + + /** + * Start streaming audio to Deepgram. + * @param {object} opts - { apiKey, language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) } + */ + async start(opts) { + this._onResult = opts.onResult; + this._onError = opts.onError; + this._onEnd = opts.onEnd; + + // 1. Get microphone access + try { + this._stream = await navigator.mediaDevices.getUserMedia({ + audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true } + }); + } catch (err) { + const msg = err.name === 'NotAllowedError' + ? 'Microphone access denied. Check browser settings.' + : 'Microphone error: ' + err.message; + this._onError?.(msg); + this._cleanup(); + return; + } + // Notify caller so it can set up audio level meter + opts.onStream?.(this._stream); + + // 2. Detect best supported MIME type for MediaRecorder + const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']; + this._selectedMime = null; + for (const mt of mimeTypes) { + if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(mt)) { + this._selectedMime = mt; + break; + } + } + + // 3. Build WebSocket URL (no encoding param — Deepgram auto-detects from container format) + + const params = new URLSearchParams({ + model: 'nova-3', + smart_format: 'false', + punctuate: 'false', + interim_results: 'true', + utterance_end_ms: '1500', + vad_events: 'true', + }); + if (opts.language && opts.language !== 'multi') { + params.set('language', opts.language); + } else if (opts.language === 'multi') { + params.set('detect_language', 'true'); + } + if (opts.keyterms?.length) { + for (const term of opts.keyterms) { + const trimmed = term.trim(); + if (trimmed) params.append('keyterm', trimmed + ':2'); + } + } + + // 4. Connect WebSocket (trim API key to avoid whitespace auth failures) + const apiKey = (opts.apiKey || '').trim(); + if (!apiKey) { + this._onError?.('No Deepgram API key configured. Add one in Settings > Voice.'); + this._cleanup(); + return; + } + const wsUrl = `wss://api.deepgram.com/v1/listen?${params}`; + try { + this._ws = new WebSocket(wsUrl, ['token', apiKey]); + } catch (err) { + this._onError?.('Failed to connect to Deepgram: ' + err.message); + this._cleanup(); + return; + } + + this._ws.onopen = () => { + // 5. Send KeepAlive every 8s to prevent Deepgram from closing idle connections + // (covers the gap before MediaRecorder produces its first chunk) + this._keepAliveInterval = setInterval(() => { + if (this._ws?.readyState === WebSocket.OPEN) { + try { this._ws.send(JSON.stringify({ type: 'KeepAlive' })); } catch (_e) { /* ignore */ } + } + }, 8000); + // 6. Start MediaRecorder once connected + this._startRecording(); + }; + + this._ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'Results' && data.channel?.alternatives?.[0]) { + const alt = data.channel.alternatives[0]; + const transcript = alt.transcript || ''; + if (transcript) { + const isFinal = data.is_final === true; + this._onResult?.(transcript, isFinal); + this._resetSilenceTimeout(); + } + } + } catch (_e) { + // Ignore parse errors for non-JSON messages + } + }; + + this._ws.onerror = () => { + // WebSocket onerror doesn't carry useful info — onclose handles it + }; + + this._ws.onclose = (event) => { + clearInterval(this._keepAliveInterval); + this._keepAliveInterval = null; + if (event.code === 1008) { + this._onError?.('Authentication failed. Check your Deepgram API key in Settings > Voice.'); + } else if (event.code === 1006) { + // 1006 = abnormal closure (no close frame). Usually auth failure, expired key, or no credits. + this._onError?.('Deepgram connection failed (1006). Check your API key is valid and has credits in Settings > Voice.'); + } else if (event.code !== 1000) { + this._onError?.('Deepgram connection closed: ' + (event.reason || `code ${event.code}`)); + } + this._stopRecording(); + this._onEnd?.(); + }; + }, + + _startRecording() { + if (!this._stream || !this._ws || this._ws.readyState !== WebSocket.OPEN) return; + + const recorderOpts = this._selectedMime ? { mimeType: this._selectedMime } : {}; + try { + this._mediaRecorder = new MediaRecorder(this._stream, recorderOpts); + } catch (err) { + this._onError?.('MediaRecorder failed: ' + err.message); + this._cleanup(); + return; + } + + this._mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0 && this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(event.data); + } + }; + + this._mediaRecorder.start(250); // Send chunks every 250ms + this._resetSilenceTimeout(); + }, + + _stopRecording() { + if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') { + try { this._mediaRecorder.stop(); } catch (_e) { /* already stopped */ } + } + // Stop all mic tracks + if (this._stream) { + this._stream.getTracks().forEach(t => t.stop()); + } + }, + + _resetSilenceTimeout() { + clearTimeout(this._silenceTimeout); + this._silenceTimeout = setTimeout(() => { + this.stop(); + }, 3000); + }, + + stop() { + clearTimeout(this._silenceTimeout); + this._silenceTimeout = null; + clearInterval(this._keepAliveInterval); + this._keepAliveInterval = null; + this._stopRecording(); + // Detach WS handlers before closing to prevent stale onclose from + // killing a subsequent recording that starts before the close completes + if (this._ws) { + this._ws.onclose = null; + this._ws.onmessage = null; + this._ws.onerror = null; + if (this._ws.readyState === WebSocket.OPEN) { + try { this._ws.close(1000); } catch (_e) { /* ignore */ } + } + this._ws = null; + } + // Save onEnd before nulling — must notify VoiceInput when silence timeout + // triggers stop internally (VoiceInput.onEnd guards with isRecording check) + const onEnd = this._onEnd; + this._onResult = null; + this._onError = null; + this._onEnd = null; + onEnd?.(); + }, + + _cleanup() { + this.stop(); + this._mediaRecorder = null; + this._stream = null; + this._selectedMime = null; + } +}; + +/** + * VoiceInput - Speech-to-text with Deepgram Nova-3 (primary) and Web Speech API (fallback). + * Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence. + * Shows interim transcription in a floating preview overlay. + * Inserts final text into the active session (user presses Enter to submit). + */ +const VoiceInput = { + recognition: null, + isRecording: false, + supported: false, + silenceTimeout: null, + previewEl: null, + _lastTranscript: '', + _stabilityTimer: null, + _accumulatedFinal: '', + _activeProvider: null, // 'deepgram' | 'webspeech' | null + _recordingStartedAt: 0, // timestamp when recording started + _retryCount: 0, // auto-retry counter for premature Web Speech API ends + _hasReceivedResult: false, // whether any speech result came in this session + _durationInterval: null, // timer for updating elapsed time display + _analyser: null, // AudioContext analyser for level meter + _analyserSource: null, // MediaStreamSource for level meter + _audioContext: null, // AudioContext for level meter + _levelAnimFrame: null, // rAF handle for level meter + + init() { + this._initRecognition(); + // Always show buttons — if unsupported, toggle() shows a toast + this._showButtons(); + }, + + // --- Deepgram config (localStorage only, never sent to server) --- + + _getDeepgramConfig() { + try { + return JSON.parse(localStorage.getItem('codeman-voice-settings') || '{}'); + } catch (_e) { + return {}; + } + }, + + _saveDeepgramConfig(config) { + localStorage.setItem('codeman-voice-settings', JSON.stringify(config)); + }, + + _shouldUseDeepgram() { + const cfg = this._getDeepgramConfig(); + return !!(cfg.apiKey && cfg.apiKey.trim()); + }, + + /** Get the active provider name for display */ + getActiveProviderName() { + if (this._shouldUseDeepgram()) return 'Deepgram Nova-3'; + if (this.supported) return 'Web Speech API'; + return 'None'; + }, + + /** Try to create a SpeechRecognition instance */ + _initRecognition() { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + this.supported = !!SR; + if (!this.supported) return; + + this.recognition = new SR(); + this.recognition.continuous = true; + this.recognition.interimResults = true; + this.recognition.lang = 'en-US'; + this.recognition.maxAlternatives = 1; + + this.recognition.onresult = (e) => this._onWebSpeechResult(e); + this.recognition.onerror = (e) => this._onWebSpeechError(e); + this.recognition.onend = () => this._onWebSpeechEnd(); + }, + + toggle() { + if (this.isRecording) { + this.stop(); + } else { + this.start(); + } + }, + + start() { + if (this.isRecording) return; + if (!app.activeSessionId) { + app.showToast('No active session', 'warning'); + return; + } + this._retryCount = 0; + + if (this._shouldUseDeepgram()) { + this._startDeepgram(); + } else { + this._startWebSpeech(); + } + }, + + _startDeepgram() { + const cfg = this._getDeepgramConfig(); + this.isRecording = true; + this._activeProvider = 'deepgram'; + this._accumulatedFinal = ''; + this._lastTranscript = ''; + this._hasReceivedResult = false; + this._recordingStartedAt = Date.now(); + this._updateButtons('recording'); + this._showPreview('Listening...', 'deepgram'); + this._startDurationTimer(); + + const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com') + .split(',').map(t => t.trim()).filter(Boolean); + + DeepgramProvider.start({ + apiKey: cfg.apiKey, + language: cfg.language || 'en-US', + keyterms, + onStream: (stream) => { + this._startLevelMeter(stream); + }, + onResult: (text, isFinal) => { + if (!this.isRecording) return; + this._hasReceivedResult = true; + if (isFinal) { + this._accumulatedFinal += text; + this._hidePreview(); + this._insertText(this._accumulatedFinal); + this.stop(); + } else { + const display = this._accumulatedFinal + text; + this._showPreview(display, 'deepgram'); + } + }, + onError: (msg) => { + const wasRecording = this.isRecording; + this.stop(); + if (wasRecording) app.showToast(msg, 'error'); + }, + onEnd: () => { + if (this.isRecording) { + if (this._accumulatedFinal) { + this._insertText(this._accumulatedFinal); + } + this.stop(); + } + } + }); + + // Haptic feedback on mobile + if (navigator.vibrate) navigator.vibrate(50); + }, + + _startWebSpeech() { + // Lazy-init: retry if recognition was cleaned up or not available at page load + if (!this.recognition) this._initRecognition(); + if (!this.supported) { + if (!this._shouldUseDeepgram()) { + app.showToast('Voice input not available. Configure Deepgram in Settings > Voice.', 'warning'); + } else { + app.showToast('Voice input not supported in this browser', 'warning'); + } + return; + } + this.isRecording = true; + this._activeProvider = 'webspeech'; + this._accumulatedFinal = ''; + this._lastTranscript = ''; + this._hasReceivedResult = false; + this._recordingStartedAt = Date.now(); + this._updateButtons('recording'); + this._showPreview('Listening...'); + this._startDurationTimer(); + try { + this.recognition.start(); + } catch (e) { + // InvalidStateError = already started — ignore. Other errors = genuine failure. + if (e.name !== 'InvalidStateError') { + this.stop(); + app.showToast('Voice input failed to start: ' + e.message, 'error'); + return; + } + } + this._resetSilenceTimeout(); + // Get mic stream for level meter (non-blocking — level meter is cosmetic) + navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => { + if (this.isRecording && this._activeProvider === 'webspeech') { + this._webSpeechStream = stream; + this._startLevelMeter(stream); + } else { + stream.getTracks().forEach(t => t.stop()); + } + }).catch(() => { /* level meter just won't show */ }); + // Haptic feedback on mobile + if (navigator.vibrate) navigator.vibrate(50); + }, + + stop() { + if (!this.isRecording) return; + this.isRecording = false; + clearTimeout(this.silenceTimeout); + clearTimeout(this._stabilityTimer); + this.silenceTimeout = null; + this._stabilityTimer = null; + this._retryCount = 0; + this._stopDurationTimer(); + this._stopLevelMeter(); + this._updateButtons('idle'); + this._hidePreview(); + + if (this._activeProvider === 'deepgram') { + DeepgramProvider.stop(); + } else if (this._activeProvider === 'webspeech') { + try { + this.recognition?.stop(); + } catch (_e) { + // Already stopped — ignore + } + // Stop the mic stream we opened for the level meter + if (this._webSpeechStream) { + this._webSpeechStream.getTracks().forEach(t => t.stop()); + this._webSpeechStream = null; + } + } + this._activeProvider = null; + + // Haptic feedback on mobile + if (navigator.vibrate) navigator.vibrate([30, 50, 30]); + }, + + _onWebSpeechResult(event) { + if (!this.isRecording) return; + this._hasReceivedResult = true; + this._resetSilenceTimeout(); + let interim = ''; + let finalText = ''; + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + finalText += transcript; + } else { + interim += transcript; + } + } + + if (finalText) { + this._accumulatedFinal += finalText; + this._hidePreview(); + this._insertText(this._accumulatedFinal); + this.stop(); + } else if (interim) { + const display = this._accumulatedFinal + interim; + this._showPreview(display); + // iOS Safari workaround: isFinal is always false. + // Detect when interim results stop changing for 750ms → treat as final. + this._iosStabilityCheck(interim); + } + }, + + _onWebSpeechError(event) { + // During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them + if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return; + + const wasRecording = this.isRecording; + this.stop(); + if (!wasRecording) return; + + switch (event.error) { + case 'not-allowed': + app.showToast('Microphone access denied. Check browser settings.', 'error'); + break; + case 'no-speech': + // Silent — auto-stop is enough feedback + break; + case 'network': + app.showToast('Voice input requires internet connection.', 'error'); + break; + case 'aborted': + // User cancelled — no message needed + break; + default: + app.showToast('Voice input error: ' + event.error, 'error'); + } + }, + + _onWebSpeechEnd() { + // Recognition ended (browser auto-stopped or we called stop()) + if (!this.isRecording) return; + + const elapsed = Date.now() - this._recordingStartedAt; + // Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results). + // Auto-retry up to 2 times to avoid the "needs two clicks" problem. + if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) { + this._retryCount++; + try { + this.recognition.start(); + } catch (_e) { + // If restart fails, fall through to stop + if (this._accumulatedFinal) this._insertText(this._accumulatedFinal); + this.stop(); + } + return; + } + + // Genuine end — finalize any accumulated text + if (this._accumulatedFinal) { + this._insertText(this._accumulatedFinal); + } + this.stop(); + }, + + _insertText(text) { + if (!app.activeSessionId || !text.trim()) return; + const trimmed = text.trim(); + const mode = this._getDeepgramConfig().insertMode || 'direct'; + + if (mode === 'compose') { + // If a compose overlay is already open, populate its textarea instead of recreating + const existingTextarea = document.querySelector('.voice-compose-overlay .paste-textarea'); + if (existingTextarea) { + existingTextarea.value = trimmed; + existingTextarea.focus(); + existingTextarea.selectionStart = existingTextarea.selectionEnd = trimmed.length; + } else { + this._showComposeOverlay(trimmed); + } + } else { + // Direct mode: inject into local echo overlay if available, else send to PTY + if (app._localEchoEnabled && app._localEchoOverlay) { + app._localEchoOverlay.appendText(trimmed); + } else { + app.sendInput(trimmed).catch(() => {}); + } + this._showVoiceSendBtn(); + setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150); + } + }, + + /** Show a green Enter button by transforming the gear icon in-place */ + _showVoiceSendBtn() { + // Find the gear button (mobile or desktop header) + const gear = document.querySelector('.btn-settings-mobile') || document.querySelector('.btn-settings'); + if (!gear || gear.classList.contains('voice-send-active')) return; + + // Remove existing if any + this._hideVoiceSendBtn(); + + // Save original state + this._voiceSendGear = gear; + this._voiceSendOriginalHTML = gear.innerHTML; + this._voiceSendOriginalOnclick = gear.getAttribute('onclick'); + + // Transform into green send button + gear.innerHTML = ''; + gear.classList.add('voice-send-active'); + gear.removeAttribute('onclick'); + gear.title = 'Send (Enter)'; + + // Click handler + this._voiceSendHandler = () => { + if (!app.activeSessionId) return; + // Simulate Enter key: if local echo is active, flush its buffer + send \r; + // otherwise just send \r directly to the PTY + if (app._localEchoEnabled && app._localEchoOverlay) { + const text = app._localEchoOverlay.pendingText || ''; + app._localEchoOverlay.clear(); + app._localEchoOverlay.suppressBufferDetection(); + if (text) app.sendInput(text).catch(() => {}); + setTimeout(() => app.sendInput('\r').catch(() => {}), 80); + } else { + app.sendInput('\r').catch(() => {}); + } + // Blink then restore + gear.classList.add('voice-send-blink'); + setTimeout(() => this._hideVoiceSendBtn(), 400); + }; + gear.addEventListener('click', this._voiceSendHandler); + }, + + _hideVoiceSendBtn() { + const gear = this._voiceSendGear; + if (!gear) return; + gear.removeEventListener('click', this._voiceSendHandler); + gear.classList.remove('voice-send-active', 'voice-send-blink'); + gear.innerHTML = this._voiceSendOriginalHTML || ''; + if (this._voiceSendOriginalOnclick) { + gear.setAttribute('onclick', this._voiceSendOriginalOnclick); + } + gear.title = 'App Settings'; + this._voiceSendGear = null; + this._voiceSendHandler = null; + this._voiceSendOriginalHTML = null; + this._voiceSendOriginalOnclick = null; + }, + + /** Show an editable compose overlay so the user can review/edit before sending */ + _showComposeOverlay(text) { + document.querySelector('.voice-compose-overlay')?.remove(); + const overlay = document.createElement('div'); + overlay.className = 'voice-compose-overlay paste-overlay'; + overlay.innerHTML = ` +
+ +
+ + + +
+
+ `; + const textarea = overlay.querySelector('textarea'); + const send = () => { + const val = textarea.value.trim(); + overlay.remove(); + if (val) app.sendInput(val + '\r').catch(() => {}); + }; + const cancel = () => overlay.remove(); + const newInput = () => { + textarea.value = ''; + textarea.blur(); + this.start(); + }; + overlay.querySelector('.paste-cancel').addEventListener('click', cancel); + overlay.querySelector('.paste-new').addEventListener('click', newInput); + overlay.querySelector('.paste-send').addEventListener('click', send); + overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); }); + document.body.appendChild(overlay); + textarea.focus(); + textarea.selectionStart = textarea.selectionEnd = textarea.value.length; + }, + + _resetSilenceTimeout() { + clearTimeout(this.silenceTimeout); + this.silenceTimeout = setTimeout(() => { + if (this.isRecording) { + // Finalize any accumulated text before stopping + if (this._accumulatedFinal) { + this._insertText(this._accumulatedFinal); + } + this.stop(); + } + }, 3000); + }, + + _iosStabilityCheck(transcript) { + if (transcript !== this._lastTranscript) { + this._lastTranscript = transcript; + clearTimeout(this._stabilityTimer); + this._stabilityTimer = setTimeout(() => { + if (this.isRecording) { + const finalText = this._accumulatedFinal + transcript; + this._hidePreview(); + this._insertText(finalText); + this.stop(); + } + }, 750); + } + }, + + _startDurationTimer() { + this._stopDurationTimer(); + this._durationInterval = setInterval(() => { + if (!this.isRecording || !this.previewEl) return; + const elapsed = Math.floor((Date.now() - this._recordingStartedAt) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const timeStr = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`; + const timerEl = this.previewEl.querySelector('.voice-timer'); + if (timerEl) timerEl.textContent = timeStr; + }, 1000); + }, + + _stopDurationTimer() { + if (this._durationInterval) { + clearInterval(this._durationInterval); + this._durationInterval = null; + } + }, + + /** Start audio level meter using AnalyserNode — attaches to the active mic stream */ + _startLevelMeter(stream) { + this._stopLevelMeter(); + try { + this._audioContext = new (window.AudioContext || window.webkitAudioContext)(); + this._analyserSource = this._audioContext.createMediaStreamSource(stream); + this._analyser = this._audioContext.createAnalyser(); + this._analyser.fftSize = 256; + this._analyserSource.connect(this._analyser); + this._drawLevelMeter(); + } catch (_e) { + // AudioContext not available — level meter just won't show + } + }, + + _stopLevelMeter() { + if (this._levelAnimFrame) { + cancelAnimationFrame(this._levelAnimFrame); + this._levelAnimFrame = null; + } + if (this._analyserSource) { + try { this._analyserSource.disconnect(); } catch (_e) { /* */ } + this._analyserSource = null; + } + if (this._audioContext) { + try { this._audioContext.close(); } catch (_e) { /* */ } + this._audioContext = null; + } + this._analyser = null; + }, + + _drawLevelMeter() { + if (!this._analyser || !this.isRecording) return; + const dataArray = new Uint8Array(this._analyser.frequencyBinCount); + this._analyser.getByteFrequencyData(dataArray); + // Compute RMS level 0-1 + let sum = 0; + for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i]; + const rms = Math.sqrt(sum / dataArray.length) / 255; + // Update the level bars in the preview + const barsEl = this.previewEl?.querySelector('.voice-level-bars'); + if (barsEl) { + const bars = barsEl.children; + for (let i = 0; i < bars.length; i++) { + const threshold = (i + 1) / bars.length; + bars[i].classList.toggle('active', rms >= threshold * 0.7); + } + } + this._levelAnimFrame = requestAnimationFrame(() => this._drawLevelMeter()); + }, + + _showPreview(text, provider) { + if (!this.previewEl) { + this.previewEl = document.createElement('div'); + this.previewEl.className = 'voice-preview'; + this.previewEl.setAttribute('aria-live', 'polite'); + document.body.appendChild(this.previewEl); + } + + // Build the indicator structure once, then just update the text node + if (!this.previewEl.querySelector('.voice-recording-indicator')) { + this.previewEl.textContent = ''; + // Recording indicator: red dot + level bars + timer + const indicator = document.createElement('span'); + indicator.className = 'voice-recording-indicator'; + indicator.innerHTML = ''; + const barsEl = document.createElement('span'); + barsEl.className = 'voice-level-bars'; + for (let i = 0; i < 5; i++) { + const bar = document.createElement('span'); + bar.className = 'voice-level-bar'; + barsEl.appendChild(bar); + } + indicator.appendChild(barsEl); + const timerEl = document.createElement('span'); + timerEl.className = 'voice-timer'; + timerEl.textContent = '0:00'; + indicator.appendChild(timerEl); + this.previewEl.appendChild(indicator); + // Provider badge for Deepgram + if (provider === 'deepgram') { + const badge = document.createElement('span'); + badge.className = 'voice-preview-badge'; + badge.textContent = 'DG'; + this.previewEl.appendChild(badge); + this.previewEl.appendChild(document.createTextNode(' ')); + } + // Text node for transcript + this._previewTextNode = document.createTextNode(text || 'Listening...'); + this.previewEl.appendChild(this._previewTextNode); + } else { + // Just update the text content + if (this._previewTextNode) { + this._previewTextNode.textContent = text || 'Listening...'; + } + } + this.previewEl.style.display = ''; + }, + + _hidePreview() { + if (this.previewEl) { + this.previewEl.style.display = 'none'; + this.previewEl.textContent = ''; + } + }, + + _updateButtons(state) { + const isRecording = state === 'recording'; + // Desktop button + const desktopBtn = document.getElementById('voiceInputBtn'); + if (desktopBtn) { + desktopBtn.classList.toggle('recording', isRecording); + desktopBtn.setAttribute('aria-pressed', String(isRecording)); + desktopBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input'); + desktopBtn.title = isRecording ? 'Stop voice input (Ctrl+Shift+V)' : 'Voice input (Ctrl+Shift+V)'; + } + // Mobile toolbar button (always visible on mobile) + const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile'); + if (mobileToolbarBtn) { + mobileToolbarBtn.classList.toggle('recording', isRecording); + mobileToolbarBtn.setAttribute('aria-pressed', String(isRecording)); + mobileToolbarBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input'); + } + }, + + _showButtons() { + const desktopBtn = document.getElementById('voiceInputBtn'); + if (desktopBtn) desktopBtn.style.display = ''; + const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile'); + if (mobileToolbarBtn) mobileToolbarBtn.style.display = ''; + }, + + /** Cleanup on SSE reconnect or page unload */ + cleanup() { + if (this.isRecording) this.stop(); + this._hideVoiceSendBtn(); + DeepgramProvider._cleanup(); + this.recognition = null; + this._activeProvider = null; + this._stopDurationTimer(); + this._stopLevelMeter(); + if (this._webSpeechStream) { + this._webSpeechStream.getTracks().forEach(t => t.stop()); + this._webSpeechStream = null; + } + if (this.previewEl) { + this.previewEl.remove(); + this.previewEl = null; + } + clearTimeout(this.silenceTimeout); + clearTimeout(this._stabilityTimer); + this.silenceTimeout = null; + this._stabilityTimer = null; + this._accumulatedFinal = ''; + this._lastTranscript = ''; + this._retryCount = 0; + this._hasReceivedResult = false; + } +};