diff --git a/CLAUDE.md b/CLAUDE.md index d43aa60e..0e0fa0e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | | **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 route modules incl. `ws-routes.ts` + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | -| **Frontend** | `src/web/public/app.js` ★ (~12.5K lines) + 11 JS modules (incl. `sw.js`, `input-cjk.js`) | | +| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 6 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 existing modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `sw.js`, `input-cjk.js`) | | | **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts | ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. @@ -143,7 +143,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `ralph-wizard.js`(7) → `api-client.js`(8) → `subagent-windows.js`(9). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). **Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7). diff --git a/scripts/build.mjs b/scripts/build.mjs index dde50490..c8c979e5 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -61,6 +61,12 @@ appendFileSync( // 4. Minify frontend assets run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); +run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); +run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite'); +run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite'); +run('minify settings-ui.js', 'npx esbuild dist/web/public/settings-ui.js --minify --outfile=dist/web/public/settings-ui.js --allow-overwrite'); +run('minify panels-ui.js', 'npx esbuild dist/web/public/panels-ui.js --minify --outfile=dist/web/public/panels-ui.js --allow-overwrite'); +run('minify session-ui.js', 'npx esbuild dist/web/public/session-ui.js --minify --outfile=dist/web/public/session-ui.js --allow-overwrite'); run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite'); run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite'); @@ -78,6 +84,12 @@ console.log('\n[build] content-hash cache busting'); 'keyboard-accessory.js', 'input-cjk.js', 'app.js', + 'terminal-ui.js', + 'respawn-ui.js', + 'ralph-panel.js', + 'settings-ui.js', + 'panels-ui.js', + 'session-ui.js', 'ralph-wizard.js', 'api-client.js', 'subagent-windows.js', diff --git a/src/web/public/api-client.js b/src/web/public/api-client.js index 092a0810..e220a170 100644 --- a/src/web/public/api-client.js +++ b/src/web/public/api-client.js @@ -7,7 +7,7 @@ * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class must be defined) - * @loadorder 8 of 9 — loaded after app.js + * @loadorder 14 of 15 — loaded after ralph-wizard.js */ // Codeman — Centralized API fetch helpers for CodemanApp diff --git a/src/web/public/app.js b/src/web/public/app.js index 9f7d714f..a501242c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1,64 +1,38 @@ /** * @fileoverview Core UI controller for Codeman — tab-based terminal manager with xterm.js. * - * This is the main application module (~11,500 lines). It defines the CodemanApp class which - * manages the entire frontend: terminal rendering, SSE event handling, session lifecycle, - * settings UI, and all panel systems. Additional methods are mixed in from api-client.js, - * ralph-wizard.js, and subagent-windows.js via Object.assign on CodemanApp.prototype. + * Defines the CodemanApp class (constructor, init, SSE connection, session lifecycle, tabs, + * navigation). Domain-specific methods are mixed in from separate modules via Object.assign: * - * ═══ Major Sections ═══ + * terminal-ui.js — Terminal setup, rendering pipeline, controls + * respawn-ui.js — Respawn banner, countdown timers, presets, run summary + * ralph-panel.js — Ralph state panel, fix_plan, plan versioning + * settings-ui.js — App settings, visibility, web push, lifecycle log, tunnel/QR, help + * panels-ui.js — Subagent panel, agent teams, project insights, file browser, log viewer, + * image popups, monitor, token stats, toast, system stats + * session-ui.js — Quick start, session options modal, case settings, mobile case picker + * ralph-wizard.js — Ralph Loop wizard modal + * api-client.js — API helper methods (fetch wrappers) + * subagent-windows.js — Floating subagent terminal windows * - * SSE Handler Map (line ~80) — Event-to-method routing table - * CodemanApp Class (line ~189) — Constructor and global state - * Pending Hooks (line ~368) — Hook state machine for tab alerts - * Init (line ~408) — App bootstrap and mobile setup - * Terminal Setup (line ~483) — xterm.js config and input handling - * Terminal Rendering (line ~1053) — batchTerminalWrite, flushPendingWrites, chunkedTerminalWrite - * Event Listeners (line ~1390) — Keyboard shortcuts, resize, beforeunload - * SSE Connection (line ~1474) — connectSSE with exponential backoff (1-30s) - * SSE Event Handlers (line ~1570) — ~60 handler methods (_onSessionCreated, _onRespawnStateChanged, etc.) - * Connection Status (line ~2553) — Online detection, handleInit (full state sync on reconnect) - * Session Tabs (line ~2911) — Tab rendering, selection, drag-and-drop reordering - * Tab Order & Drag-and-Drop (~3143) — Persistent tab ordering with drag reorder - * Session Lifecycle (line ~3268) — Select, close, navigate sessions - * Navigation (line ~3673) — goHome, Ralph wizard stub - * Quick Start (line ~3709) — Case loading, session spawning (Claude, Shell, OpenCode) - * Respawn Banner (line ~4205) — Respawn state display, countdown timers, action log - * Kill Sessions (line ~4640) — Kill active/all sessions - * Terminal Controls (line ~4678) — Clear, resize, copy, font size, sendInput - * Timer / Tokens (line ~4833) — Session timer, token/cost display - * Session Options Modal (line ~4939) — Per-session settings, respawn config, color picker - * Respawn Presets (line ~5188) — Preset CRUD, load/save/delete - * Run Summary Modal (line ~5506) — Timeline events, filtering, export (JSON/Markdown) - * Session Options Tabs (line ~5762) — Ralph config tab within session options - * Web Push (line ~5882) — Service worker registration, push subscribe/unsubscribe - * App Settings Modal (line ~6024) — Global settings, tunnel management, QR auth, voice config - * Session Lifecycle Log (line ~6536) — JSONL audit log viewer - * Visibility Settings (line ~6859) — Header/panel visibility, device-specific defaults - * Persistent Parent Assoc (line ~7218) — Parent session tracking for subagent windows - * Help Modal (line ~7326) — Keyboard shortcuts help - * Token Statistics (line ~7363) — Aggregate token/cost stats across sessions - * Monitor Panel (line ~7490) — Mux sessions + background tasks, detachable panel - * Subagents Panel (line ~7614) — Detachable subagent list panel - * Ralph Panel (line ~7799) — Ralph Loop status, @fix_plan.md integration - * Plan Versioning (line ~8638) — Plan checkpoint/rollback/diff UI - * Subagent Panel (line ~8780) — Agent discovery, window open/close, connection lines - * Subagent Parent Tracking (~9101) — Tab-based agent grouping - * Agent Teams (line ~9595) — Team tasks panel, teammate badges - * Project Insights (line ~9995) — Bash tool tracking with clickable file paths - * File Browser (line ~10310) — Directory tree panel with file preview - * Log Viewer (line ~10619) — Floating file streamer windows (tail -f) - * Image Popups (line ~10768) — Auto-popup windows for detected screenshots - * Mux Sessions (line ~10909) — tmux session list in monitor panel - * Case Settings (line ~10986) — Case CRUD and link management - * Mobile Case Picker (line ~11221) — Touch-friendly case selection modal - * Plan Wizard Agents (line ~11480) — Plan orchestrator subagent display in monitor - * Toast (line ~11566) — Toast notification popups - * System Stats (line ~11618) — CPU/memory polling display - * Module Init (line ~11696) — localStorage migration and app start + * ═══ Sections in this file ═══ * - * After the class: localStorage migration (claudeman-* → codeman-*), app instantiation, - * and window.app / window.MobileDetection exports. + * SSE Handler Map — Event-to-method routing table (resolves at runtime via `this`) + * CodemanApp Class — Constructor and all state initialization (~80 properties) + * Pending Hooks — Hook state machine for tab alerts + * Init — App bootstrap, mobile setup, WebGL init + * Event Listeners — Keyboard shortcuts, resize, beforeunload + * SSE Connection — connectSSE with exponential backoff (1-30s) + * Core SSE Event Handlers — Session lifecycle, scheduled runs (~20 handlers) + * Connection Status — Online detection, input queuing, state sync + * WebSocket Terminal I/O — Low-latency WS bypass for terminal input + * Session Tabs — Tab rendering, selection, drag-and-drop reordering + * Tab Order & Drag-and-Drop — Persistent ordering with localStorage sync + * Session Lifecycle — Select, close, navigate, rename, cleanup + * Navigation — goHome + * Kill Sessions — Kill active/all sessions + * Timer / Tokens — Session timer, token/cost display + * Module Init — localStorage migration, app instantiation * * @class CodemanApp * @globals {CodemanApp} app - Singleton instance (also on window.app) @@ -70,7 +44,7 @@ * @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay) - * @loadorder 6 of 9 — loaded after keyboard-accessory.js, before ralph-wizard.js + * @loadorder 6 of 15 — loaded after keyboard-accessory.js, before terminal-ui.js */ // Codeman App - Tab-based Terminal UI @@ -155,8 +129,6 @@ HTMLCanvasElement.prototype.getContext = function(type, ...args) { }; - - // ═══════════════════════════════════════════════════════════════ // SSE Handler Map — event-to-method routing table // ═══════════════════════════════════════════════════════════════ @@ -351,6 +323,17 @@ class CodemanApp { this.imagePopups = new Map(); // Map this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; + // File browser state (methods in panels-ui.js) + this.fileBrowserData = null; + this.fileBrowserExpandedDirs = new Set(); + this.fileBrowserFilter = ''; + this.fileBrowserAllExpanded = false; + this.fileBrowserDragListeners = null; + this.filePreviewContent = ''; + + // Toast container cache (methods in panels-ui.js) + this._toastContainer = null; + // Tunnel indicator state this._tunnelUrl = null; @@ -585,1088 +568,6 @@ class CodemanApp { } catch (_e) { /* WebGL2 unavailable — canvas renderer used */ } } - // ═══════════════════════════════════════════════════════════════ - // Terminal Setup — xterm.js config and input handling - // ═══════════════════════════════════════════════════════════════ - - initTerminal() { - // Load scrollback setting from localStorage (default 5000) - const scrollback = parseInt(localStorage.getItem('codeman-scrollback')) || DEFAULT_SCROLLBACK; - - this.terminal = new Terminal({ - theme: { - background: '#0d0d0d', - foreground: '#e0e0e0', - cursor: '#e0e0e0', - cursorAccent: '#0d0d0d', - selection: 'rgba(255, 255, 255, 0.3)', - black: '#0d0d0d', - red: '#ff6b6b', - green: '#51cf66', - yellow: '#ffd43b', - blue: '#339af0', - magenta: '#cc5de8', - cyan: '#22b8cf', - white: '#e0e0e0', - brightBlack: '#495057', - brightRed: '#ff8787', - brightGreen: '#69db7c', - brightYellow: '#ffe066', - brightBlue: '#5c7cfa', - brightMagenta: '#da77f2', - brightCyan: '#66d9e8', - brightWhite: '#ffffff', - }, - fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', - // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) - fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, - lineHeight: 1.2, - cursorBlink: false, - cursorStyle: 'block', - scrollback: scrollback, - allowTransparency: true, - allowProposedApi: true, - }); - - this.fitAddon = new FitAddon.FitAddon(); - this.terminal.loadAddon(this.fitAddon); - - if (typeof Unicode11Addon !== 'undefined') { - try { - const unicode11Addon = new Unicode11Addon.Unicode11Addon(); - this.terminal.loadAddon(unicode11Addon); - this.terminal.unicode.activeVersion = '11'; - } catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ } - } - - const container = document.getElementById('terminalContainer'); - this.terminal.open(container); - - // Suppress xterm key handling during CJK IME composition. - // Without this, xterm processes raw keyDown events (e.g., "Process" key) - // during composition, causing duplicate or garbled input. - this.terminal.attachCustomKeyEventHandler((ev) => { - if (ev.isComposing || ev.keyCode === 229) return false; - return true; - }); - - // WebGL renderer for GPU-accelerated terminal rendering. - // Previously caused "page unresponsive" crashes from synchronous GPU stalls, - // but the 48KB/frame flush cap in flushPendingWrites() now prevents - // oversized terminal.write() calls that triggered the stalls. - // Disable with ?nowebgl URL param if GPU issues return. - // Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile). - this._webglAddon = null; - const skipWebGL = MobileDetection.getDeviceType() !== 'desktop'; - if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl')) { - if (typeof WebglAddon !== 'undefined') { - this._initWebGL(); - } else { - // Lazy-load WebGL addon — not bundled in to avoid blocking mobile - const wglScript = document.createElement('script'); - wglScript.src = 'vendor/xterm-addon-webgl.min.js'; - wglScript.onload = () => this._initWebGL(); - wglScript.onerror = () => console.warn('[CRASH-DIAG] Failed to load WebGL addon — using canvas renderer'); - document.head.appendChild(wglScript); - } - } - - this._localEchoOverlay = new LocalEchoOverlay(this.terminal); - - // CJK IME input — textarea in index.html, just wire up send - this._cjkInput = null; - if (typeof CjkInput !== 'undefined') { - this._cjkInput = CjkInput.init({ - send: (text) => { - if (this.activeSessionId) { - this._sendInputAsync(this.activeSessionId, text); - } - }, - }); - } - - // On mobile Safari, delay initial fit() to allow layout to settle - // This prevents 0-column terminals caused by fit() running before container is sized - const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' && - document.body.classList.contains('safari-browser'); - if (isMobileSafari) { - // Wait for layout, then fit multiple times to ensure proper sizing - requestAnimationFrame(() => { - this.fitAddon.fit(); - // Double-check after another frame - requestAnimationFrame(() => this.fitAddon.fit()); - }); - } else { - this.fitAddon.fit(); - } - - // Register link provider for clickable file paths in Bash tool output - this.registerFilePathLinkProvider(); - - // Always use mouse wheel for terminal scrollback, never forward to application. - // Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation. - container.addEventListener('wheel', (ev) => { - ev.preventDefault(); - const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1); - this.terminal.scrollLines(lines); - }, { passive: false }); - - // Touch scrolling - only use custom JS scrolling on desktop - // Mobile uses native browser scrolling via CSS touch-action: pan-y - const isMobileDevice = MobileDetection.isTouchDevice() && window.innerWidth < 1024; - - if (!isMobileDevice) { - // Desktop touch scrolling with custom momentum - let touchLastY = 0; - let pendingDelta = 0; - let velocity = 0; - let lastTime = 0; - let scrollFrame = null; - let isTouching = false; - - const viewport = container.querySelector('.xterm-viewport'); - - // Single RAF loop handles both touch and momentum - const scrollLoop = (timestamp) => { - if (!viewport) return; - - const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; // Normalize to 60fps - lastTime = timestamp; - - if (isTouching) { - // During touch: apply pending delta - if (pendingDelta !== 0) { - viewport.scrollTop += pendingDelta; - pendingDelta = 0; - } - scrollFrame = requestAnimationFrame(scrollLoop); - } else if (Math.abs(velocity) > 0.1) { - // Momentum phase - viewport.scrollTop += velocity * dt; - velocity *= 0.94; // Smooth deceleration - scrollFrame = requestAnimationFrame(scrollLoop); - } else { - scrollFrame = null; - velocity = 0; - } - }; - - container.addEventListener('touchstart', (ev) => { - if (ev.touches.length === 1) { - touchLastY = ev.touches[0].clientY; - pendingDelta = 0; - velocity = 0; - isTouching = true; - lastTime = 0; - if (!scrollFrame) { - scrollFrame = requestAnimationFrame(scrollLoop); - } - } - }, { passive: true }); - - container.addEventListener('touchmove', (ev) => { - if (ev.touches.length === 1 && isTouching) { - const touchY = ev.touches[0].clientY; - const delta = touchLastY - touchY; - pendingDelta += delta; - velocity = delta * 1.2; // Track for momentum - touchLastY = touchY; - } - }, { passive: true }); - - container.addEventListener('touchend', () => { - isTouching = false; - // Momentum continues in scrollLoop - }, { passive: true }); - - container.addEventListener('touchcancel', () => { - isTouching = false; - velocity = 0; - }, { passive: true }); - } - // Mobile: native scrolling handles touch via CSS - - // Welcome message - this.showWelcome(); - - // Handle resize with throttling for performance - this._resizeTimeout = null; - this._lastResizeDims = null; - - // Minimum terminal dimensions to prevent vertical text wrapping - const MIN_COLS = 40; - const MIN_ROWS = 10; - - const throttledResize = () => { - // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens - // once after the user stops resizing. During active resize, the terminal - // stays at its old dimensions for up to 300ms. - // - // Why not fit() immediately? Each fitAddon.fit() reflows content at the - // new width — lines that were 7 rows become 10, and the overflow gets - // pushed into scrollback. With continuous resize events, this creates - // dozens of intermediate reflow states in scrollback, appearing as - // duplicate/garbled content when the user scrolls up. - // - // By deferring fit() to the trailing edge, there's exactly ONE reflow - // at the final dimensions, ONE viewport clear, and ONE Ink redraw. - if (this._resizeTimeout) { - clearTimeout(this._resizeTimeout); - } - this._resizeTimeout = setTimeout(() => { - this._resizeTimeout = null; - // Fit xterm.js to final container dimensions - if (this.fitAddon) { - this.fitAddon.fit(); - } - // Flush any stale flicker buffer before clearing viewport - if (this.flickerFilterBuffer) { - if (this.flickerFilterTimeout) { - clearTimeout(this.flickerFilterTimeout); - this.flickerFilterTimeout = null; - } - this.flushFlickerBuffer(); - } - // Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH. - // fitAddon.fit() reflows content: lines at old width may wrap to more rows, - // pushing overflow into scrollback. Ink's cursor-up count is based on the - // pre-reflow line count, so ghost renders accumulate in scrollback. - // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris, - // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw. - const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; - if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended - && this.terminal && this.isTerminalAtBottom()) { - this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); - } - // Skip server resize while mobile keyboard is visible — sending SIGWINCH - // causes Ink to re-render at the new row count, garbling terminal output. - // Local fit() still runs so xterm knows the viewport size for scrolling. - const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; - if (this.activeSessionId && !keyboardUp) { - const dims = this.fitAddon.proposeDimensions(); - // Enforce minimum dimensions to prevent layout issues - const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; - const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; - // Only send resize if dimensions actually changed - if (!this._lastResizeDims || - cols !== this._lastResizeDims.cols || - rows !== this._lastResizeDims.rows) { - this._lastResizeDims = { cols, rows }; - fetch(`/api/sessions/${this.activeSessionId}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols, rows }) - }).catch(() => {}); - } - } - // Update subagent connection lines and local echo at new dimensions - this.updateConnectionLines(); - if (this._localEchoOverlay?.hasPending) { - this._localEchoOverlay.rerender(); - } - }, 300); // Trailing-edge: only fire after 300ms of no resize events - }; - - window.addEventListener('resize', throttledResize); - // Store resize observer for cleanup (prevents memory leak on terminal re-init) - if (this.terminalResizeObserver) { - this.terminalResizeObserver.disconnect(); - } - this.terminalResizeObserver = new ResizeObserver(throttledResize); - this.terminalResizeObserver.observe(container); - - // Handle keyboard input — send to PTY immediately, no local echo. - // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug). - this._pendingInput = ''; - this._inputFlushTimeout = null; - this._lastKeystrokeTime = 0; - - const flushInput = () => { - this._inputFlushTimeout = null; - if (this._pendingInput && this.activeSessionId) { - const input = this._pendingInput; - const sessionId = this.activeSessionId; - this._pendingInput = ''; - this._sendInputAsync(sessionId, input); - } - }; - - // Local echo mode: buffer keystrokes locally (shown in overlay) and only - // send to PTY on Enter. Avoids out-of-order delivery on high-latency - // mobile connections. The overlay + localStorage persistence ensure input - // survives tab switches and reconnects. - - this.terminal.onData((data) => { - // CJK input has focus — block xterm from sending to PTY - if (window.cjkActive || document.activeElement?.id === 'cjkInput') return; - if (this.activeSessionId) { - // Filter out terminal query responses that xterm.js generates automatically. - // These are responses to DA (Device Attributes), DSR (Device Status Report), etc. - // sent by tmux when attaching. Without this filter, they appear as typed text. - // Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR) - if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return; - - // ── Local Echo Mode ── - // When enabled, keystrokes are buffered locally in the overlay for - // instant visual feedback. Nothing is sent to the PTY until Enter - // (or a control char) is pressed — avoids out-of-order char delivery. - if (this._localEchoEnabled) { - if (data === '\x7f') { - const source = this._localEchoOverlay?.removeChar(); - if (source === 'flushed') { - // Sync app-level flushed Maps (per-session state for tab switching) - const { count, text } = this._localEchoOverlay.getFlushed(); - if (this._flushedOffsets?.has(this.activeSessionId)) { - if (count === 0) { - this._flushedOffsets.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - } else { - this._flushedOffsets.set(this.activeSessionId, count); - this._flushedTexts?.set(this.activeSessionId, text); - } - } - this._pendingInput += data; - flushInput(); - } - // 'pending' = removed unsent text (no PTY backspace needed) - // false = nothing to remove (swallow the backspace) - return; - } - if (/^[\r\n]+$/.test(data)) { - // Enter: send full buffered text + \r to PTY in one shot - const text = this._localEchoOverlay?.pendingText || ''; - this._localEchoOverlay?.clear(); - // Suppress detection so PTY-echoed text isn't re-detected as user input - this._localEchoOverlay?.suppressBufferDetection(); - // Clear flushed offset and text — Enter commits all text - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - if (text) { - this._pendingInput += text; - flushInput(); - } - // Send \r after a short delay so text arrives first - setTimeout(() => { - this._pendingInput += '\r'; - flushInput(); - }, 80); - return; - } - if (data.length > 1 && data.charCodeAt(0) >= 32) { - // Paste: append to overlay only (sent on Enter) - this._localEchoOverlay?.appendText(data); - return; - } - if (data.charCodeAt(0) < 32) { - // Skip xterm-generated terminal responses. - // These arrive via triggerDataEvent when the terminal processes - // buffer data (DA responses, OSC color queries, mode reports, etc.). - // They are NOT user input and must not clear flushed text state. - // Covers: CSI (\x1b[), OSC (\x1b]), DCS (\x1bP), APC (\x1b_), - // PM (\x1b^), SOS (\x1bX), and any other multi-byte ESC sequence. - // Single-byte ESC (user pressing Escape) still falls through to - // the control char handler below. - if (data.length > 1 && data.charCodeAt(0) === 27) { - // Multi-byte escape sequence — forward to PTY without clearing - // overlay/flushed state (terminal response, not user input) - this._pendingInput += data; - flushInput(); - return; - } - // During buffer load (tab switch), stray control chars from - // terminal response processing must not wipe the flushed state - // that selectSession() is actively restoring. - if (this._restoringFlushedState) { - this._pendingInput += data; - flushInput(); - return; - } - // Tab key: send pending text + Tab to PTY for tab completion. - // Set a flag so flushPendingWrites() re-detects buffer text when - // the PTY response arrives (event-driven, no fixed timer). - if (data === '\t') { - const text = this._localEchoOverlay?.pendingText || ''; - this._localEchoOverlay?.clear(); - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (text) { - this._pendingInput += text; - } - this._pendingInput += data; - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - // Snapshot prompt line text BEFORE flushing — used to distinguish - // real Tab completions from pre-existing Claude UI text. - let baseText = ''; - try { - const p = this._localEchoOverlay?.findPrompt?.(); - if (p) { - const buf = this.terminal.buffer.active; - const line = buf.getLine(buf.viewportY + p.row); - if (line) baseText = line.translateToString(true).slice(p.col + 2).trimEnd(); - } - } catch {} - this._tabCompletionBaseText = baseText; - flushInput(); - this._tabCompletionSessionId = this.activeSessionId; - this._tabCompletionRetries = 0; - // Fallback: if flushPendingWrites() detection misses the completion - // (e.g., flicker filter delays data, or xterm hasn't processed writes - // by the time the callback fires), retry detection after a delay. - // This ensures the overlay renders even without further terminal data. - if (this._tabCompletionFallback) clearTimeout(this._tabCompletionFallback); - const selfTab = this; - this._tabCompletionFallback = setTimeout(() => { - selfTab._tabCompletionFallback = null; - if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return; - const ov = selfTab._localEchoOverlay; - if (!ov || ov.pendingText) return; - selfTab.terminal.write('', () => { - if (!selfTab._tabCompletionSessionId) return; - ov.resetBufferDetection(); - const detected = ov.detectBufferText(); - if (detected && detected !== selfTab._tabCompletionBaseText) { - selfTab._tabCompletionSessionId = null; - selfTab._tabCompletionRetries = 0; - selfTab._tabCompletionBaseText = null; - ov.rerender(); - } - }); - }, 300); - return; - } - // Control chars (Ctrl+C, single ESC): send buffered text + control char immediately - const text = this._localEchoOverlay?.pendingText || ''; - this._localEchoOverlay?.clear(); - // Suppress detection so PTY-echoed text isn't re-detected as user input - this._localEchoOverlay?.suppressBufferDetection(); - // Clear flushed offset and text — control chars (Ctrl+C, Escape) change - // cursor position or abort readline, making flushed text tracking invalid. - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (text) { - this._pendingInput += text; - } - this._pendingInput += data; - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - flushInput(); - return; - } - if (data.length === 1 && data.charCodeAt(0) >= 32) { - // Printable char: add to overlay only (sent on Enter) - this._localEchoOverlay?.addChar(data); - return; - } - } - - // ── Normal Mode (echo disabled) ── - this._pendingInput += data; - - // Control chars (Enter, Ctrl+C, escape sequences) — flush immediately - if (data.charCodeAt(0) < 32 || data.length > 1) { - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - flushInput(); - return; - } - - // Regular chars — flush immediately if typed after a gap (>50ms), - // otherwise batch via microtask to coalesce rapid keystrokes (paste). - const now = performance.now(); - if (now - this._lastKeystrokeTime > 50) { - // Single char after a gap — send immediately, no setTimeout latency - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - this._lastKeystrokeTime = now; - flushInput(); - } else { - // Rapid sequence (paste or fast typing) — coalesce via microtask - this._lastKeystrokeTime = now; - if (!this._inputFlushTimeout) { - this._inputFlushTimeout = setTimeout(flushInput, 0); - } - } - } - }); - } - - /** - * Register a custom link provider for xterm.js that detects file paths - * in terminal output and makes them clickable. - * When clicked, opens a floating log viewer window with live streaming. - */ - registerFilePathLinkProvider() { - const self = this; - - // Debug: Track if provider is being invoked - let lastInvokedLine = -1; - - this.terminal.registerLinkProvider({ - provideLinks(bufferLineNumber, callback) { - // Debug logging - only log if line changed to avoid spam - if (bufferLineNumber !== lastInvokedLine) { - lastInvokedLine = bufferLineNumber; - console.debug('[LinkProvider] Checking line:', bufferLineNumber); - } - - const buffer = self.terminal.buffer.active; - const line = buffer.getLine(bufferLineNumber); - - if (!line) { - callback(undefined); - return; - } - - // Get line text - translateToString handles wrapped lines - const lineText = line.translateToString(true); - - if (!lineText || !lineText.includes('/')) { - callback(undefined); - return; - } - - const links = []; - - // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.) - // Handles: tail -f /path, grep pattern /path, cat -n /path - const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; - - // Pattern 2: Paths with common extensions - const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g; - - // Pattern 3: Bash() tool output - const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; - - const addLink = (filePath, matchIndex) => { - const startCol = lineText.indexOf(filePath, matchIndex); - if (startCol === -1) return; - - // Skip if already have link at this position - if (links.some(l => l.range.start.x === startCol + 1)) return; - - links.push({ - text: filePath, - range: { - start: { x: startCol + 1, y: bufferLineNumber }, // 1-based - end: { x: startCol + filePath.length + 1, y: bufferLineNumber } - }, - decorations: { - pointerCursor: true, - underline: true - }, - activate(event, text) { - self.openLogViewerWindow(text, self.activeSessionId); - } - }); - }; - - // Match all patterns - let match; - - cmdPattern.lastIndex = 0; - while ((match = cmdPattern.exec(lineText)) !== null) { - addLink(match[2], match.index); - } - - extPattern.lastIndex = 0; - while ((match = extPattern.exec(lineText)) !== null) { - addLink(match[1], match.index); - } - - bashPattern.lastIndex = 0; - while ((match = bashPattern.exec(lineText)) !== null) { - addLink(match[1], match.index); - } - - if (links.length > 0) { - console.debug('[LinkProvider] Found links:', links.map(l => l.text)); - } - callback(links.length > 0 ? links : undefined); - } - }); - - console.log('[LinkProvider] File path link provider registered'); - } - - showWelcome() { - const overlay = document.getElementById('welcomeOverlay'); - if (overlay) { - overlay.classList.add('visible'); - this.loadTunnelStatus(); - this.loadHistorySessions(); - } - } - - hideWelcome() { - const overlay = document.getElementById('welcomeOverlay'); - if (overlay) { - overlay.classList.remove('visible'); - } - // Collapse expanded QR when leaving welcome screen - const qrWrap = document.getElementById('welcomeQr'); - if (qrWrap) { - clearTimeout(this._welcomeQrShrinkTimer); - qrWrap.classList.remove('expanded'); - } - } - - /** - * Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total). - * @returns {Promise} deduplicated session list, sorted by lastModified desc - */ - async _fetchHistorySessions(limit = 12) { - const res = await fetch('/api/history/sessions'); - const data = await res.json(); - const sessions = data.sessions || []; - if (sessions.length === 0) return []; - - const byDir = new Map(); - for (const s of sessions) { - if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []); - byDir.get(s.workingDir).push(s); - } - const items = []; - for (const [, group] of byDir) { - items.push(...group.slice(0, 2)); - } - items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); - return items.slice(0, limit); - } - - async loadHistorySessions() { - const container = document.getElementById('historySessions'); - const list = document.getElementById('historyList'); - if (!container || !list) return; - - try { - const display = await this._fetchHistorySessions(12); - if (display.length === 0) { - container.style.display = 'none'; - return; - } - - // Build DOM safely (no innerHTML with user data) - list.replaceChildren(); - for (const s of display) { - const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B` - : s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K` - : `${(s.sizeBytes / 1048576).toFixed(1)}M`; - const date = new Date(s.lastModified); - const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) - + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); - const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); - - const item = document.createElement('div'); - item.className = 'history-item'; - item.title = s.workingDir; - item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir)); - - const dirSpan = document.createElement('span'); - dirSpan.className = 'history-item-dir'; - dirSpan.textContent = shortDir; - - const metaSpan = document.createElement('span'); - metaSpan.className = 'history-item-meta'; - metaSpan.textContent = timeStr; - - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'history-item-size'; - sizeSpan.textContent = size; - - item.append(dirSpan, metaSpan, sizeSpan); - list.appendChild(item); - } - - container.style.display = ''; - } catch (err) { - console.error('[loadHistorySessions]', err); - container.style.display = 'none'; - } - } - - async resumeHistorySession(sessionId, workingDir) { - // Close the run mode menu if open - document.getElementById('runModeMenu')?.classList.remove('active'); - try { - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`); - - // Generate a session name from the working dir - const dirName = workingDir.split('/').pop() || 'session'; - let startNumber = 1; - for (const [, session] of this.sessions) { - const match = session.name && session.name.match(/^w(\d+)-/); - if (match) { - const num = parseInt(match[1]); - if (num >= startNumber) startNumber = num + 1; - } - } - const name = `w${startNumber}-${dirName}`; - - // Create session with resumeSessionId - const createRes = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }) - }); - const createData = await createRes.json(); - if (!createData.success) throw new Error(createData.error); - - const newSessionId = createData.session.id; - - // Start interactive - await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' }); - - this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`); - await this.selectSession(newSessionId); - this.terminal.focus(); - } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Terminal Rendering - // ═══════════════════════════════════════════════════════════════ - - /** - * Check if terminal viewport is at or near the bottom. - * Used to implement "sticky scroll" behavior - keep user at bottom if they were there. - */ - isTerminalAtBottom() { - if (!this.terminal) return true; - const buffer = this.terminal.buffer.active; - // viewportY is the top line of the viewport, baseY is where scrollback starts - // If viewportY >= baseY, we're showing the latest content (at bottom) - // Allow 2 lines tolerance for edge cases - return buffer.viewportY >= buffer.baseY - 2; - } - - batchTerminalWrite(data) { - // If a buffer load (chunkedTerminalWrite) is in progress, queue live events - // to prevent interleaving historical buffer data with live SSE data. - // This is critical: interleaving causes cursor position chaos with Ink redraws. - if (this._isLoadingBuffer) { - if (this._loadBufferQueue) this._loadBufferQueue.push(data); - return; - } - - // Check if at bottom BEFORE adding data (captures user's scroll position) - // Only update if not already scheduled (preserve the first check's result) - if (!this.writeFrameScheduled) { - this._wasAtBottomBeforeWrite = this.isTerminalAtBottom(); - } - - // Check if flicker filter is enabled for current session - const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; - const flickerFilterEnabled = session?.flickerFilterEnabled ?? false; - - // xterm.js 6.0 handles DEC 2026 synchronized output natively — Ink's cursor-up - // redraws are wrapped in 2026h/2026l markers and rendered atomically by xterm.js. - // No client-side cursor-up detection/buffering needed. The old 50ms flicker filter - // was actively harmful: it accumulated multiple resize redraws and flushed them - // together, causing stacked ghost renders due to reflow line-count mismatches. - - // Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it) - if (flickerFilterEnabled) { - const hasScreenClear = data.includes('\x1b[2J') || - data.includes('\x1b[H\x1b[J') || - (data.includes('\x1b[H') && data.includes('\x1b[?25l')); - - if (hasScreenClear) { - this.flickerFilterActive = true; - this.flickerFilterBuffer += data; - - if (this.flickerFilterTimeout) { - clearTimeout(this.flickerFilterTimeout); - } - this.flickerFilterTimeout = setTimeout(() => { - this.flickerFilterTimeout = null; - this.flushFlickerBuffer(); - }, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window - - return; - } - - if (this.flickerFilterActive) { - this.flickerFilterBuffer += data; - return; - } - } - - // Accumulate raw data (may contain DEC 2026 markers) - this.pendingWrites.push(data); - - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - requestAnimationFrame(() => { - // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers - // content between 2026h/2026l and renders atomically. No need for - // client-side incomplete-block detection; just flush every frame. - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); - } - } - - /** - * Flush the flicker filter buffer to the terminal. - * Called after the buffer window expires. - */ - flushFlickerBuffer() { - if (!this.flickerFilterBuffer) return; - - // Transfer buffered data to normal pending writes - this.pendingWrites.push(this.flickerFilterBuffer); - this.flickerFilterBuffer = ''; - this.flickerFilterActive = false; - - // Trigger a normal flush - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - requestAnimationFrame(() => { - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); - } - } - - /** - * Update local echo overlay state based on settings. - * Enabled whenever the setting is on — works during idle AND busy. - * Position is tracked dynamically by _findPrompt() on every render. - */ - _updateLocalEchoState() { - const settings = this.loadAppSettingsFromStorage(); - const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; - const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); - const shouldEnable = !!(echoEnabled && session); - if (this._localEchoEnabled && !shouldEnable) { - this._localEchoOverlay?.clear(); - } - this._localEchoEnabled = shouldEnable; - - // Swap prompt finder based on session mode - if (this._localEchoOverlay && session) { - if (session.mode === 'opencode') { - // OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row. - // The input area is "┃ " — the ┃ is the anchor, offset 3 skips "┃ ". - // We use the cursor row (cursorY) to find the right line, then scan for ┃. - this._localEchoOverlay.setPrompt({ - type: 'custom', - offset: 3, - find: (terminal) => { - try { - const buf = terminal.buffer.active; - const row = buf.cursorY; - const line = buf.getLine(buf.viewportY + row); - if (!line) return null; - const text = line.translateToString(true); - const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL) - if (idx >= 0) return { row, col: idx }; - return null; - } catch { return null; } - } - }); - } else if (session.mode === 'shell') { - // Shell mode: the shell provides its own PTY echo so the overlay isn't needed. - // Disable it by clearing any pending text. - this._localEchoOverlay.clear(); - this._localEchoEnabled = false; - } else { - // Claude Code: scan for ❯ prompt character - this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 }); - } - } - } - - /** - * Flush pending writes to terminal, processing DEC 2026 sync markers. - * Strips markers and writes content atomically within a single frame. - */ - flushPendingWrites() { - if (this.pendingWrites.length === 0 || !this.terminal) return; - - const _t0 = performance.now(); - // xterm.js 6.0+ natively handles DEC 2026 synchronized output markers. - // Pass raw data through — xterm.js buffers content between markers and - // renders atomically, eliminating split-frame Ink redraws. - const joined = this.pendingWrites.join(''); - this.pendingWrites = []; - const _joinedLen = joined.length; - if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`); - - // Per-frame byte budget to prevent main thread blocking. - // Large writes (141KB+) can freeze Chrome for 2+ minutes. - const MAX_FRAME_BYTES = 65536; // 64KB budget per frame - let deferred = false; - - if (_joinedLen <= MAX_FRAME_BYTES) { - this.terminal.write(joined); - } else { - // Write first chunk now, defer rest to next frame - this.terminal.write(joined.slice(0, MAX_FRAME_BYTES)); - this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES)); - deferred = true; - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - requestAnimationFrame(() => { - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); - } - } - const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen; - const _dt = performance.now() - _t0; - if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`); - - // Sticky scroll: if user was at bottom, keep them there after new output - if (this._wasAtBottomBeforeWrite) { - this.terminal.scrollToBottom(); - } - - // Re-position local echo overlay after terminal writes — Ink redraws can - // move the ❯ prompt to a different row, making the overlay invisible. - if (this._localEchoOverlay?.hasPending) { - this._localEchoOverlay.rerender(); - } - - // After Tab completion: detect the completed text in the overlay. - // Use terminal.write('', callback) to defer detection until xterm.js - // finishes processing ALL queued writes — direct buffer reads after - // terminal.write(data) can miss text if xterm processes asynchronously. - if (this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId - && this._localEchoOverlay && !this._localEchoOverlay.pendingText) { - const overlay = this._localEchoOverlay; - const self = this; - this.terminal.write('', () => { - if (!self._tabCompletionSessionId) return; // already resolved - overlay.resetBufferDetection(); - const detected = overlay.detectBufferText(); - if (detected) { - if (detected === self._tabCompletionBaseText) { - // Same text as before Tab — no completion yet. Undo and retry. - overlay.undoDetection(); - self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1; - if (self._tabCompletionRetries > 60) { - self._tabCompletionSessionId = null; - self._tabCompletionRetries = 0; - } - } else { - // Text changed — real completion happened - self._tabCompletionSessionId = null; - self._tabCompletionRetries = 0; - self._tabCompletionBaseText = null; - if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; } - overlay.rerender(); - } - } else { - // No text found yet — retry on next flush. - self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1; - if (self._tabCompletionRetries > 60) { - self._tabCompletionSessionId = null; - self._tabCompletionRetries = 0; - } - } - }); - } - } - - /** - * Write large buffer to terminal in chunks to avoid UI jank. - * Uses requestAnimationFrame to spread work across frames. - * @param {string} buffer - The full terminal buffer to write - * @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps) - * @returns {Promise} - Resolves when all chunks written - */ - chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) { - return new Promise((resolve) => { - if (!buffer || buffer.length === 0) { - this._finishBufferLoad(); - resolve(); - return; - } - - // Block live SSE writes during buffer load to prevent interleaving - this._isLoadingBuffer = true; - this._loadBufferQueue = []; - - // Strip any DEC 2026 markers that might be in the buffer - // (from historical SSE data that was stored with markers) - const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); - - const finish = () => { - this._finishBufferLoad(); - resolve(); - }; - - // For small buffers, write directly — single-frame render is fast enough - if (cleanBuffer.length <= chunkSize) { - this.terminal.write(cleanBuffer); - finish(); - return; - } - - // Large buffers: write in chunks across animation frames. - // Each 32KB chunk keeps per-frame WebGL render work under ~5ms, - // avoiding GPU stalls without needing to toggle the renderer. - let offset = 0; - const _chunkStart = performance.now(); - let _chunkCount = 0; - const writeChunk = () => { - if (offset >= cleanBuffer.length) { - const _totalMs = performance.now() - _chunkStart; - console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`); - // Wait one more frame for xterm to finish rendering before resolving - requestAnimationFrame(finish); - return; - } - - const _ct0 = performance.now(); - const chunk = cleanBuffer.slice(offset, offset + chunkSize); - this.terminal.write(chunk); - const _cdt = performance.now() - _ct0; - _chunkCount++; - if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`); - offset += chunkSize; - - // Schedule next chunk on next frame - requestAnimationFrame(writeChunk); - }; - - // Start writing - requestAnimationFrame(writeChunk); - }); - } - - /** - * Complete a buffer load: unblock live SSE writes and flush any queued events. - * Called when chunkedTerminalWrite finishes (or is skipped for empty buffers). - */ - _finishBufferLoad() { - const queue = this._loadBufferQueue; - this._isLoadingBuffer = false; - this._loadBufferQueue = null; - if (queue && queue.length > 0) { - for (const data of queue) { - this.batchTerminalWrite(data); - } - } - } - // ═══════════════════════════════════════════════════════════════ // Event Listeners (Keyboard Shortcuts, Resize, Beforeunload) // ═══════════════════════════════════════════════════════════════ @@ -2161,730 +1062,6 @@ class CodemanApp { this.hideTimer(); } - // Respawn - _onRespawnStarted(data) { - this.respawnStatus[data.sessionId] = data.status; - if (data.sessionId === this.activeSessionId) { - this.showRespawnBanner(); - } - } - - _onRespawnStopped(data) { - delete this.respawnStatus[data.sessionId]; - if (data.sessionId === this.activeSessionId) { - this.hideRespawnBanner(); - } - } - - _onRespawnStateChanged(data) { - if (this.respawnStatus[data.sessionId]) { - this.respawnStatus[data.sessionId].state = data.state; - } - if (data.sessionId === this.activeSessionId) { - this.updateRespawnBanner(data.state); - } - } - - _onRespawnCycleStarted(data) { - if (this.respawnStatus[data.sessionId]) { - this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber; - } - if (data.sessionId === this.activeSessionId) { - document.getElementById('respawnCycleCount').textContent = data.cycleNumber; - } - } - - _onRespawnBlocked(data) { - const session = this.sessions.get(data.sessionId); - const reasonMap = { - circuit_breaker_open: 'Circuit Breaker Open', - exit_signal: 'Exit Signal Detected', - status_blocked: 'Claude Reported BLOCKED', - }; - const title = reasonMap[data.reason] || 'Respawn Blocked'; - this.notificationManager?.notify({ - urgency: 'critical', - category: 'respawn-blocked', - sessionId: data.sessionId, - sessionName: session?.name || this.getShortId(data.sessionId), - title, - message: data.details, - }); - // Update respawn panel to show blocked state - if (data.sessionId === this.activeSessionId) { - const stateEl = document.getElementById('respawnStateLabel'); - if (stateEl) { - stateEl.textContent = title; - stateEl.classList.add('respawn-blocked'); - } - } - } - - _onRespawnAutoAcceptSent(data) { - const session = this.sessions.get(data.sessionId); - this.notificationManager?.notify({ - urgency: 'info', - category: 'auto-accept', - sessionId: data.sessionId, - sessionName: session?.name || this.getShortId(data.sessionId), - title: 'Plan Accepted', - message: `Accepted plan mode for ${session?.name || 'session'}`, - }); - } - - _onRespawnDetectionUpdate(data) { - if (this.respawnStatus[data.sessionId]) { - this.respawnStatus[data.sessionId].detection = data.detection; - } - if (data.sessionId === this.activeSessionId) { - this.updateDetectionDisplay(data.detection); - } - } - - // Merged handler for respawn:timerStarted — handles both run timers (data.endAt) - // and controller countdown timers (data.timer). Previously registered as two - // separate addListener calls (duplicate event bug). - _onRespawnTimerStarted(data) { - // Run timer (timed respawn runs) - if (data.endAt) { - this.respawnTimers[data.sessionId] = { - endAt: data.endAt, - startedAt: data.startedAt, - durationMinutes: data.durationMinutes - }; - if (data.sessionId === this.activeSessionId) { - this.showRespawnTimer(); - } - } - // Controller countdown timer (internal timers) - if (data.timer) { - const { sessionId, timer } = data; - if (!this.respawnCountdownTimers[sessionId]) { - this.respawnCountdownTimers[sessionId] = {}; - } - this.respawnCountdownTimers[sessionId][timer.name] = { - endsAt: timer.endsAt, - totalMs: timer.durationMs, - reason: timer.reason - }; - if (sessionId === this.activeSessionId) { - this.updateCountdownTimerDisplay(); - this.startCountdownInterval(); - } - } - } - - _onRespawnTimerCancelled(data) { - const { sessionId, timerName } = data; - if (this.respawnCountdownTimers[sessionId]) { - delete this.respawnCountdownTimers[sessionId][timerName]; - } - if (sessionId === this.activeSessionId) { - this.updateCountdownTimerDisplay(); - } - } - - _onRespawnTimerCompleted(data) { - const { sessionId, timerName } = data; - if (this.respawnCountdownTimers[sessionId]) { - delete this.respawnCountdownTimers[sessionId][timerName]; - } - if (sessionId === this.activeSessionId) { - this.updateCountdownTimerDisplay(); - } - } - - _onRespawnError(data) { - const session = this.sessions.get(data.sessionId); - this.notificationManager?.notify({ - urgency: 'critical', - category: 'session-error', - sessionId: data.sessionId, - sessionName: session?.name || data.sessionId, - title: 'Respawn Error', - message: data.error || data.message || 'Respawn encountered an error', - }); - } - - _onRespawnActionLog(data) { - const { sessionId, action } = data; - this.addActionLogEntry(sessionId, action); - if (sessionId === this.activeSessionId) { - this.updateCountdownTimerDisplay(); // Show row if hidden - this.updateActionLogDisplay(); - } - } - - // Tasks - _onTaskCreated(data) { - this.renderSessionTabs(); - if (data.sessionId === this.activeSessionId) { - this.renderTaskPanel(); - } - } - - _onTaskCompleted(data) { - this.renderSessionTabs(); - if (data.sessionId === this.activeSessionId) { - this.renderTaskPanel(); - } - } - - _onTaskFailed(data) { - this.renderSessionTabs(); - if (data.sessionId === this.activeSessionId) { - this.renderTaskPanel(); - } - } - - _onTaskUpdated(data) { - if (data.sessionId === this.activeSessionId) { - this.renderTaskPanel(); - } - } - - // Mux (tmux) - _onMuxCreated(data) { - this.muxSessions.push(data); - this.renderMuxSessions(); - } - - _onMuxKilled(data) { - this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId); - this.renderMuxSessions(); - } - - _onMuxDied(data) { - this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId); - this.renderMuxSessions(); - this.showToast('Mux session died: ' + this.getShortId(data.sessionId), 'warning'); - } - - _onMuxStatsUpdated(data) { - this.muxSessions = data; - if (document.getElementById('monitorPanel').classList.contains('open')) { - this.renderMuxSessions(); - } - } - - // Ralph - _onRalphLoopUpdate(data) { - // Skip if user explicitly closed this session's Ralph panel - if (this.ralphClosedSessions.has(data.sessionId)) return; - this.updateRalphState(data.sessionId, { loop: data.state }); - } - - _onRalphTodoUpdate(data) { - // Skip if user explicitly closed this session's Ralph panel - if (this.ralphClosedSessions.has(data.sessionId)) return; - this.updateRalphState(data.sessionId, { todos: data.todos }); - } - - _onRalphCompletionDetected(data) { - // Skip if user explicitly closed this session's Ralph panel - if (this.ralphClosedSessions.has(data.sessionId)) return; - // Prevent duplicate notifications for the same completion - const completionKey = `${data.sessionId}:${data.phrase}`; - if (this._shownCompletions?.has(completionKey)) { - return; - } - if (!this._shownCompletions) { - this._shownCompletions = new Set(); - } - this._shownCompletions.add(completionKey); - // Clear after 30 seconds to allow re-notification if loop restarts - setTimeout(() => this._shownCompletions?.delete(completionKey), 30000); - - // Update ralph state to mark loop as inactive - const existing = this.ralphStates.get(data.sessionId) || {}; - if (existing.loop) { - existing.loop.active = false; - this.updateRalphState(data.sessionId, existing); - } - - const session = this.sessions.get(data.sessionId); - this.notificationManager?.notify({ - urgency: 'warning', - category: 'ralph-complete', - sessionId: data.sessionId, - sessionName: session?.name || this.getShortId(data.sessionId), - title: 'Loop Complete', - message: `Completion: ${data.phrase || 'unknown'}`, - }); - } - - _onRalphStatusUpdate(data) { - // Skip if user explicitly closed this session's Ralph panel - if (this.ralphClosedSessions.has(data.sessionId)) return; - this.updateRalphState(data.sessionId, { statusBlock: data.block }); - } - - _onCircuitBreakerUpdate(data) { - // Skip if user explicitly closed this session's Ralph panel - if (this.ralphClosedSessions.has(data.sessionId)) return; - this.updateRalphState(data.sessionId, { circuitBreaker: data.status }); - // Notify if circuit breaker opens - if (data.status.state === 'OPEN') { - const session = this.sessions.get(data.sessionId); - this.notificationManager?.notify({ - urgency: 'critical', - category: 'circuit-breaker', - sessionId: data.sessionId, - sessionName: session?.name || this.getShortId(data.sessionId), - title: 'Circuit Breaker Open', - message: data.status.reason || 'Loop stuck - no progress detected', - }); - } - } - - _onExitGateMet(data) { - const session = this.sessions.get(data.sessionId); - this.notificationManager?.notify({ - urgency: 'warning', - category: 'exit-gate', - sessionId: data.sessionId, - sessionName: session?.name || this.getShortId(data.sessionId), - title: 'Exit Gate Met', - message: `Loop ready to exit (indicators: ${data.completionIndicators})`, - }); - } - - // Bash tools - _onBashToolStart(data) { - this.handleBashToolStart(data.sessionId, data.tool); - } - - _onBashToolEnd(data) { - this.handleBashToolEnd(data.sessionId, data.tool); - } - - _onBashToolsUpdate(data) { - this.handleBashToolsUpdate(data.sessionId, data.tools); - } - - // Hooks (Claude Code hook events) - _onHookIdlePrompt(data) { - const session = this.sessions.get(data.sessionId); - // Always track pending hook - alert will show when switching away from session - if (data.sessionId) { - this.setPendingHook(data.sessionId, 'idle_prompt'); - } - this.notificationManager?.notify({ - urgency: 'warning', - category: 'hook-idle', - sessionId: data.sessionId, - sessionName: session?.name || data.sessionId, - title: 'Waiting for Input', - message: data.message || 'Claude is idle and waiting for a prompt', - }); - } - - _onHookPermissionPrompt(data) { - const session = this.sessions.get(data.sessionId); - // Always track pending hook - action alerts need user interaction to clear - if (data.sessionId) { - this.setPendingHook(data.sessionId, 'permission_prompt'); - } - const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : ''; - this.notificationManager?.notify({ - urgency: 'critical', - category: 'hook-permission', - sessionId: data.sessionId, - sessionName: session?.name || data.sessionId, - title: 'Permission Required', - message: toolInfo || 'Claude needs tool approval to continue', - }); - } - - _onHookElicitationDialog(data) { - const session = this.sessions.get(data.sessionId); - // Always track pending hook - action alerts need user interaction to clear - if (data.sessionId) { - this.setPendingHook(data.sessionId, 'elicitation_dialog'); - } - this.notificationManager?.notify({ - urgency: 'critical', - category: 'hook-elicitation', - sessionId: data.sessionId, - sessionName: session?.name || data.sessionId, - title: 'Question Asked', - message: data.question || 'Claude is asking a question and waiting for your answer', - }); - } - - _onHookStop(data) { - const session = this.sessions.get(data.sessionId); - // Clear all pending hooks when Claude finishes responding - if (data.sessionId) { - this.clearPendingHooks(data.sessionId); - } - this.notificationManager?.notify({ - urgency: 'info', - category: 'hook-stop', - sessionId: data.sessionId, - sessionName: session?.name || data.sessionId, - title: 'Response Complete', - message: data.reason || 'Claude has finished responding', - }); - } - - _onHookTeammateIdle(data) { - const session = this.sessions.get(data.sessionId); - this.notificationManager?.notify({ - urgency: 'warning', - category: 'hook-teammate-idle', - sessionId: data.sessionId, - sessionName: session?.name || data.sessionId, - title: 'Teammate Idle', - message: `A teammate is idle in ${session?.name || data.sessionId}`, - }); - } - - _onHookTaskCompleted(data) { - const session = this.sessions.get(data.sessionId); - this.notificationManager?.notify({ - urgency: 'info', - category: 'hook-task-completed', - sessionId: data.sessionId, - sessionName: session?.name || data.sessionId, - title: 'Task Completed', - message: `A team task completed in ${session?.name || data.sessionId}`, - }); - } - - // Subagents (Claude Code background agents) - _onSubagentDiscovered(data) { - // Clear all old data for this agentId (in case of ID reuse) - this.subagents.set(data.agentId, data); - this.subagentActivity.set(data.agentId, []); - this.subagentToolResults.delete(data.agentId); - // Close any existing window for this agentId (will be reopened fresh) - if (this.subagentWindows.has(data.agentId)) { - this.forceCloseSubagentWindow(data.agentId); - } - this.renderSubagentPanel(); - - // Find which Codeman session owns this subagent (direct claudeSessionId match only) - this.findParentSessionForSubagent(data.agentId); - - // Auto-open window for new active agents — but ONLY if they belong to a Codeman session tab. - // Agents from external Claude sessions (not managed by Codeman) should not pop up. - if (data.status === 'active') { - const agentForCheck = this.subagents.get(data.agentId); - const hasMatchingTab = agentForCheck?.sessionId && - Array.from(this.sessions.values()).some(s => s.claudeSessionId === agentForCheck.sessionId); - if (hasMatchingTab) { - this.openSubagentWindow(data.agentId); - } - } - - // Ensure connection lines are updated after window is created and DOM settles - requestAnimationFrame(() => { - this.updateConnectionLines(); - }); - - // Notify about new subagent discovery - const parentId = this.subagentParentMap.get(data.agentId); - const parentSession = parentId ? this.sessions.get(parentId) : null; - this.notificationManager?.notify({ - urgency: 'info', - category: 'subagent-spawn', - sessionId: parentId || data.sessionId, - sessionName: parentSession?.name || parentId || data.sessionId, - title: 'Subagent Spawned', - message: data.description || 'New background agent started', - }); - } - - _onSubagentUpdated(data) { - const existing = this.subagents.get(data.agentId); - if (existing) { - // Merge updated fields (especially description) - Object.assign(existing, data); - this.subagents.set(data.agentId, existing); - } else { - this.subagents.set(data.agentId, data); - } - this.renderSubagentPanel(); - // Update floating window if open (content + header/title) - if (this.subagentWindows.has(data.agentId)) { - this.renderSubagentWindowContent(data.agentId); - this.updateSubagentWindowHeader(data.agentId); - } - } - - _onSubagentToolCall(data) { - const activity = this.subagentActivity.get(data.agentId) || []; - activity.push({ type: 'tool', ...data }); - if (activity.length > 50) activity.shift(); // Keep last 50 entries - this.subagentActivity.set(data.agentId, activity); - if (this.activeSubagentId === data.agentId) { - this.renderSubagentDetail(); - } - this.renderSubagentPanel(); - // Update floating window (debounced — tool_call events fire rapidly) - if (this.subagentWindows.has(data.agentId)) { - this.scheduleSubagentWindowRender(data.agentId); - } - } - - _onSubagentProgress(data) { - const activity = this.subagentActivity.get(data.agentId) || []; - activity.push({ type: 'progress', ...data }); - if (activity.length > 50) activity.shift(); - this.subagentActivity.set(data.agentId, activity); - if (this.activeSubagentId === data.agentId) { - this.renderSubagentDetail(); - } - // Update floating window (debounced) - if (this.subagentWindows.has(data.agentId)) { - this.scheduleSubagentWindowRender(data.agentId); - } - } - - _onSubagentMessage(data) { - const activity = this.subagentActivity.get(data.agentId) || []; - activity.push({ type: 'message', ...data }); - if (activity.length > 50) activity.shift(); - this.subagentActivity.set(data.agentId, activity); - if (this.activeSubagentId === data.agentId) { - this.renderSubagentDetail(); - } - // Update floating window (debounced) - if (this.subagentWindows.has(data.agentId)) { - this.scheduleSubagentWindowRender(data.agentId); - } - } - - _onSubagentToolResult(data) { - // Store tool result by toolUseId for later lookup (cap at 50 per agent) - if (!this.subagentToolResults.has(data.agentId)) { - this.subagentToolResults.set(data.agentId, new Map()); - } - const resultsMap = this.subagentToolResults.get(data.agentId); - resultsMap.set(data.toolUseId, data); - if (resultsMap.size > 50) { - const oldest = resultsMap.keys().next().value; - resultsMap.delete(oldest); - } - - // Add to activity stream - const activity = this.subagentActivity.get(data.agentId) || []; - activity.push({ type: 'tool_result', ...data }); - if (activity.length > 50) activity.shift(); - this.subagentActivity.set(data.agentId, activity); - - if (this.activeSubagentId === data.agentId) { - this.renderSubagentDetail(); - } - // Update floating window (debounced) - if (this.subagentWindows.has(data.agentId)) { - this.scheduleSubagentWindowRender(data.agentId); - } - } - - async _onSubagentCompleted(data) { - const existing = this.subagents.get(data.agentId); - if (existing) { - existing.status = 'completed'; - this.subagents.set(data.agentId, existing); - } - this.renderSubagentPanel(); - this.updateSubagentWindows(); - - // Auto-minimize completed subagent windows - if (this.subagentWindows.has(data.agentId)) { - const windowData = this.subagentWindows.get(data.agentId); - if (windowData && !windowData.minimized) { - await this.closeSubagentWindow(data.agentId); // This minimizes to tab - this.saveSubagentWindowStates(); // Persist the minimized state - } - } - - // Notify about subagent completion - const parentId = this.subagentParentMap.get(data.agentId); - const parentSession = parentId ? this.sessions.get(parentId) : null; - this.notificationManager?.notify({ - urgency: 'info', - category: 'subagent-complete', - sessionId: parentId || existing?.sessionId || data.sessionId, - sessionName: parentSession?.name || parentId || data.sessionId, - title: 'Subagent Completed', - message: existing?.description || data.description || 'Background agent finished', - }); - - // Clean up activity/tool data for completed agents after 5 minutes - // This prevents memory leaks from long-running sessions with many subagents - setTimeout(() => { - const agent = this.subagents.get(data.agentId); - // Only clean up if agent is still completed (not restarted) - if (agent?.status === 'completed') { - this.subagentActivity.delete(data.agentId); - this.subagentToolResults.delete(data.agentId); - } - }, 5 * 60 * 1000); // 5 minutes - - // Prune stale completed agents from main maps after 30 minutes - // Keeps subagents/subagentParentMap from growing unbounded in 24h sessions - setTimeout(() => { - const agent = this.subagents.get(data.agentId); - if (agent?.status === 'completed' && !this.subagentWindows.has(data.agentId)) { - this.subagents.delete(data.agentId); - this.subagentParentMap.delete(data.agentId); - } - }, 30 * 60 * 1000); // 30 minutes - } - - // Images - _onImageDetected(data) { - console.log('[Image Detected]', data); - this.openImagePopup(data); - } - - // Tunnel - _onTunnelStarted(data) { - console.log('[Tunnel] Started:', data.url); - this._tunnelUrl = data.url; - this._dismissTunnelConnecting(); - this._updateTunnelUrlDisplay(data.url); - this._updateTunnelIndicator(true); - const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible'); - if (welcomeVisible) { - // On welcome screen: QR appears inline, expanded first - this._updateWelcomeTunnelBtn(true, data.url, true); - this.showToast(`Tunnel active`, 'success'); - } else { - // Not on welcome screen: popup QR overlay - this._updateWelcomeTunnelBtn(true, data.url); - this.showToast(`Tunnel active: ${data.url}`, 'success'); - this.showTunnelQR(); - } - } - - _onTunnelStopped() { - console.log('[Tunnel] Stopped'); - this._tunnelUrl = null; - this._dismissTunnelConnecting(); - this._updateTunnelUrlDisplay(null); - this._updateWelcomeTunnelBtn(false); - this._updateTunnelIndicator(false); - this.closeTunnelPanel(); - this.closeTunnelQR(); - } - - _onTunnelProgress(data) { - console.log('[Tunnel] Progress:', data.message); - const toast = document.getElementById('tunnelConnectingToast'); - if (toast) { - toast.innerHTML = ` ${data.message}`; - } - // Also update button text if on welcome screen - const btn = document.getElementById('welcomeTunnelBtn'); - if (btn?.classList.contains('connecting')) { - btn.innerHTML = ` ${data.message}`; - } - } - - _onTunnelError(data) { - console.warn('[Tunnel] Error:', data.message); - this._dismissTunnelConnecting(); - this.showToast(`Tunnel error: ${data.message}`, 'error'); - const btn = document.getElementById('welcomeTunnelBtn'); - if (btn) { btn.disabled = false; btn.classList.remove('connecting'); } - } - - _onTunnelQrRotated(data) { - if (data.svg) { - const container = document.getElementById('tunnelQrContainer'); - if (container) container.innerHTML = data.svg; - const welcomeInner = document.getElementById('welcomeQrInner'); - if (welcomeInner) welcomeInner.innerHTML = data.svg; - } else { - this._refreshTunnelQrFromApi(); - } - this._resetQrCountdown(); - } - - _onTunnelQrRegenerated(data) { - if (data.svg) { - const container = document.getElementById('tunnelQrContainer'); - if (container) container.innerHTML = data.svg; - const welcomeInner = document.getElementById('welcomeQrInner'); - if (welcomeInner) welcomeInner.innerHTML = data.svg; - } else { - this._refreshTunnelQrFromApi(); - } - this._resetQrCountdown(); - } - - _onTunnelQrAuthUsed(data) { - const ua = data.ua || 'Unknown device'; - const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser'; - this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', { - duration: 10000, - action: { label: 'Revoke All', onClick: () => { - fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) - .then(() => this.showToast('All sessions revoked', 'success')) - .catch(() => this.showToast('Failed to revoke sessions', 'error')); - }}, - }); - } - - // Plan orchestration - _onPlanSubagent(data) { - console.log('[Plan Subagent]', data); - this.handlePlanSubagentEvent(data); - } - - _onPlanProgress(data) { - console.log('[Plan Progress]', data); - - // Update UI if we have a progress handler registered - if (this._planProgressHandler) { - this._planProgressHandler({ type: 'plan:progress', data }); - } - - // Also update the loading display directly for better feedback - const titleEl = document.getElementById('planLoadingTitle'); - const hintEl = document.getElementById('planLoadingHint'); - - if (titleEl && data.phase) { - const phaseLabels = { - 'parallel-analysis': 'Running parallel analysis...', - 'subagent': data.detail || 'Subagent working...', - 'synthesis': 'Synthesizing results...', - 'verification': 'Running verification...', - }; - titleEl.textContent = phaseLabels[data.phase] || data.phase; - } - if (hintEl && data.detail) { - hintEl.textContent = data.detail; - } - } - - _onPlanStarted(data) { - console.log('[Plan Started]', data); - this.activePlanOrchestratorId = data.orchestratorId; - this.planGenerationStopped = false; // Reset flag for new generation - this.renderMonitorPlanAgents(); - } - - _onPlanCancelled(data) { - console.log('[Plan Cancelled]', data); - if (this.activePlanOrchestratorId === data.orchestratorId) { - this.activePlanOrchestratorId = null; - } - this.renderMonitorPlanAgents(); - } - - _onPlanCompleted(data) { - console.log('[Plan Completed]', data); - if (this.activePlanOrchestratorId === data.orchestratorId) { - this.activePlanOrchestratorId = null; - } - this.renderMonitorPlanAgents(); - } - // ═══════════════════════════════════════════════════════════════ // Connection Status, Input Queuing & State Initialization // ═══════════════════════════════════════════════════════════════ @@ -4273,986 +2450,6 @@ class CodemanApp { planLoadingTimer = null; planLoadingStartTime = null; - // ═══════════════════════════════════════════════════════════════ - // Quick Start - // ═══════════════════════════════════════════════════════════════ - - async loadQuickStartCases(selectCaseName = null, settingsPromise = null) { - try { - // Load settings to get lastUsedCase (reuse shared promise if provided) - let lastUsedCase = null; - try { - const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); - if (settings) { - lastUsedCase = settings.lastUsedCase || null; - } - } catch { - // Ignore settings load errors - } - - const res = await fetch('/api/cases'); - const cases = await res.json(); - this.cases = cases; - console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase); - - const select = document.getElementById('quickStartCase'); - - // Build options - existing cases first, then testcase as fallback if not present - let options = ''; - const hasTestcase = cases.some(c => c.name === 'testcase'); - const isMobile = MobileDetection.getDeviceType() === 'mobile'; - const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile - - cases.forEach(c => { - const displayName = c.name.length > maxNameLength - ? c.name.substring(0, maxNameLength) + '…' - : c.name; - options += ``; - }); - - // Add testcase option if it doesn't exist (will be created on first run) - if (!hasTestcase) { - options = `` + options; - } - - select.innerHTML = options; - console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200)); - - // If a specific case was requested, select it - if (selectCaseName) { - select.value = selectCaseName; - this.updateDirDisplayForCase(selectCaseName); - this.updateMobileCaseLabel(selectCaseName); - } else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) { - // Use lastUsedCase if available and exists - select.value = lastUsedCase; - this.updateDirDisplayForCase(lastUsedCase); - this.updateMobileCaseLabel(lastUsedCase); - } else if (cases.length > 0) { - // Fallback to testcase or first case - const firstCase = cases.find(c => c.name === 'testcase') || cases[0]; - select.value = firstCase.name; - this.updateDirDisplayForCase(firstCase.name); - this.updateMobileCaseLabel(firstCase.name); - } else { - // No cases exist yet - show the default case name as directory - select.value = 'testcase'; - document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase'; - this.updateMobileCaseLabel('testcase'); - } - - // Only add event listener once (on first load) - if (!select.dataset.listenerAdded) { - select.addEventListener('change', () => { - this.updateDirDisplayForCase(select.value); - this.saveLastUsedCase(select.value); - this.updateMobileCaseLabel(select.value); - }); - select.dataset.listenerAdded = 'true'; - } - } catch (err) { - console.error('Failed to load cases:', err); - } - } - - async updateDirDisplayForCase(caseName) { - try { - const res = await fetch(`/api/cases/${caseName}`); - const data = await res.json(); - if (data.path) { - document.getElementById('dirDisplay').textContent = data.path; - document.getElementById('dirInput').value = data.path; - } - } catch (err) { - document.getElementById('dirDisplay').textContent = caseName; - } - } - - async saveLastUsedCase(caseName) { - try { - // Get current settings - const res = await fetch('/api/settings'); - const settings = res.ok ? await res.json() : {}; - // Update lastUsedCase - settings.lastUsedCase = caseName; - // Save back - await fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings) - }); - } catch (err) { - console.error('Failed to save last used case:', err); - } - } - - async quickStart() { - return this.run(); - } - - /** Run using the selected mode (Claude Code or OpenCode) */ - async run() { - const mode = this._runMode || 'claude'; - if (mode === 'opencode') { - return this.runOpenCode(); - } - return this.runClaude(); - } - - /** Get/set the run mode, persisted in localStorage */ - get runMode() { return this._runMode || 'claude'; } - - setRunMode(mode) { - this._runMode = mode; - try { localStorage.setItem('codeman_runMode', mode); } catch {} - this._applyRunMode(); - // Sync to server for cross-device persistence - this._apiPut('/api/settings', { runMode: mode }).catch(() => {}); - // Close menu - document.getElementById('runModeMenu')?.classList.remove('active'); - } - - toggleRunModeMenu(e) { - e?.stopPropagation(); - const menu = document.getElementById('runModeMenu'); - if (!menu) return; - menu.classList.toggle('active'); - // Update selected state - menu.querySelectorAll('.run-mode-option').forEach(btn => { - btn.classList.toggle('selected', btn.dataset.mode === this.runMode); - }); - // Load history sessions when menu opens - if (menu.classList.contains('active')) { - this._loadRunModeHistory(); - const close = (ev) => { - if (!menu.contains(ev.target)) { - menu.classList.remove('active'); - document.removeEventListener('click', close); - } - }; - setTimeout(() => document.addEventListener('click', close), 0); - } - } - - async _loadRunModeHistory() { - const container = document.getElementById('runModeHistory'); - if (!container) return; - container.innerHTML = '
Loading...
'; - - try { - const display = await this._fetchHistorySessions(10); - if (display.length === 0) { - container.innerHTML = '
No history
'; - return; - } - - // Build items using DOM API for reliable mobile touch handling - container.replaceChildren(); - for (const s of display) { - const date = new Date(s.lastModified); - const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) - + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); - const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); - - const btn = document.createElement('button'); - btn.className = 'run-mode-option'; - btn.title = s.workingDir; - btn.dataset.sessionId = s.sessionId; - btn.dataset.workingDir = s.workingDir; - - const dirSpan = document.createElement('span'); - dirSpan.className = 'hist-dir'; - dirSpan.textContent = shortDir; - - const metaSpan = document.createElement('span'); - metaSpan.className = 'hist-meta'; - metaSpan.textContent = timeStr; - - btn.append(dirSpan, metaSpan); - btn.addEventListener('click', (e) => { - e.stopPropagation(); - this.resumeHistorySession(s.sessionId, s.workingDir); - }); - container.appendChild(btn); - } - } catch (err) { - container.innerHTML = '
Failed to load
'; - } - } - - _applyRunMode() { - const mode = this.runMode; - const runBtn = document.getElementById('runBtn'); - const gearBtn = runBtn?.nextElementSibling; - const label = document.getElementById('runBtnLabel'); - if (runBtn) { - runBtn.className = `btn-toolbar btn-run mode-${mode}`; - } - if (gearBtn) { - gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; - } - if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : 'Run'; - } - } - - _initRunMode() { - try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; } - this._applyRunMode(); - } - - // Tab count stepper functions - incrementTabCount() { - const input = document.getElementById('tabCount'); - const current = parseInt(input.value) || 1; - input.value = Math.min(20, current + 1); - } - - decrementTabCount() { - const input = document.getElementById('tabCount'); - const current = parseInt(input.value) || 1; - input.value = Math.max(1, current - 1); - } - - // Shell count stepper functions - incrementShellCount() { - const input = document.getElementById('shellCount'); - const current = parseInt(input.value) || 1; - input.value = Math.min(20, current + 1); - } - - decrementShellCount() { - const input = document.getElementById('shellCount'); - const current = parseInt(input.value) || 1; - input.value = Math.max(1, current - 1); - } - - async runClaude() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1)); - - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); - - try { - // Get case path first - const caseRes = await fetch(`/api/cases/${caseName}`); - let caseData = await caseRes.json(); - - // Create the case if it doesn't exist - if (!caseData.path) { - const createCaseRes = await fetch('/api/cases', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName, description: '' }) - }); - const createCaseData = await createCaseRes.json(); - if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case'); - // Use the newly created case data (API returns { success, case: { name, path } }) - caseData = createCaseData.case; - } - - const workingDir = caseData.path; - if (!workingDir) throw new Error('Case path not found'); - let firstSessionId = null; - - // Find the highest existing w-number for THIS case to avoid duplicates - let startNumber = 1; - for (const [, session] of this.sessions) { - const match = session.name && session.name.match(/^w(\d+)-(.+)$/); - if (match && match[2] === caseName) { - const num = parseInt(match[1]); - if (num >= startNumber) { - startNumber = num + 1; - } - } - } - - // Get global Ralph tracker setting - const ralphEnabled = this.isRalphTrackerEnabledByDefault(); - - // Create all sessions in parallel for speed - const sessionNames = []; - for (let i = 0; i < tabCount; i++) { - sessionNames.push(`w${startNumber + i}-${caseName}`); - } - - // Build env overrides from global + case settings (case overrides global) - const caseSettings = this.getCaseSettings(caseName); - const globalSettings = this.loadAppSettingsFromStorage(); - const envOverrides = {}; - if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) { - envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; - } - const hasEnvOverrides = Object.keys(envOverrides).length > 0; - - // Step 1: Create all sessions in parallel - this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`); - const createPromises = sessionNames.map(name => - fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) }) - }).then(r => r.json()) - ); - const createResults = await Promise.all(createPromises); - - // Collect created session IDs - const sessionIds = []; - for (const result of createResults) { - if (!result.success) throw new Error(result.error); - sessionIds.push(result.session.id); - } - firstSessionId = sessionIds[0]; - - // Step 2: Configure Ralph for all sessions in parallel - await Promise.all(sessionIds.map(id => - fetch(`/api/sessions/${id}/ralph-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled }) - }) - )); - - // Step 3: Start all sessions in parallel (biggest speedup) - this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`); - await Promise.all(sessionIds.map(id => - fetch(`/api/sessions/${id}/interactive`, { method: 'POST' }) - )); - - this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`); - - // Auto-switch to the new session using selectSession (does proper refresh) - if (firstSessionId) { - await this.selectSession(firstSessionId); - this.loadQuickStartCases(); - } - - this.terminal.focus(); - } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); - } - } - - /** Send Ctrl+C to the active session to stop the current operation. - * Requires double-tap: first tap turns button amber, second tap within 2s sends Ctrl+C. */ - stopClaude() { - if (!this.activeSessionId) return; - const btn = document.querySelector('.btn-toolbar.btn-stop'); - if (!btn) return; - - if (this._stopConfirmTimer) { - // Second tap — send Ctrl+C - clearTimeout(this._stopConfirmTimer); - this._stopConfirmTimer = null; - btn.innerHTML = btn.dataset.origHtml; - delete btn.dataset.origHtml; - btn.classList.remove('confirming'); - fetch(`/api/sessions/${this.activeSessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: '\x03' }) - }); - } else { - // First tap — enter confirm state - btn.dataset.origHtml = btn.innerHTML; - btn.textContent = 'Tap again'; - btn.classList.add('confirming'); - this._stopConfirmTimer = setTimeout(() => { - this._stopConfirmTimer = null; - if (btn.dataset.origHtml) { - btn.innerHTML = btn.dataset.origHtml; - delete btn.dataset.origHtml; - } - btn.classList.remove('confirming'); - }, 2000); - } - } - - async runShell() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); - - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); - - try { - // Get the case path - const caseRes = await fetch(`/api/cases/${caseName}`); - const caseData = await caseRes.json(); - const workingDir = caseData.path; - if (!workingDir) throw new Error('Case path not found'); - - // Find the highest existing s-number for THIS case to avoid duplicates - let startNumber = 1; - for (const [, session] of this.sessions) { - const match = session.name && session.name.match(/^s(\d+)-(.+)$/); - if (match && match[2] === caseName) { - const num = parseInt(match[1]); - if (num >= startNumber) { - startNumber = num + 1; - } - } - } - - // Create all shell sessions in parallel - const sessionNames = []; - for (let i = 0; i < shellCount; i++) { - sessionNames.push(`s${startNumber + i}-${caseName}`); - } - - // Step 1: Create all sessions in parallel - const createPromises = sessionNames.map(name => - fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir, mode: 'shell', name }) - }).then(r => r.json()) - ); - const createResults = await Promise.all(createPromises); - - const sessionIds = []; - for (const result of createResults) { - if (!result.success) throw new Error(result.error); - sessionIds.push(result.session.id); - } - - // Step 2: Start all shells in parallel - await Promise.all(sessionIds.map(id => - fetch(`/api/sessions/${id}/shell`, { method: 'POST' }) - )); - - // Step 3: Resize all in parallel (with minimum dimension enforcement) - const dims = this.getTerminalDimensions(); - if (dims) { - await Promise.all(sessionIds.map(id => - fetch(`/api/sessions/${id}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(dims) - }) - )); - } - - // Switch to first session - if (sessionIds.length > 0) { - this.activeSessionId = sessionIds[0]; - await this.selectSession(sessionIds[0]); - } - - this.terminal.focus(); - } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); - } - } - - async runOpenCode() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); - - try { - // Check if OpenCode is available - const statusRes = await fetch('/api/opencode/status'); - const status = await statusRes.json(); - if (!status.available) { - this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m'); - return; - } - - // Quick-start with opencode mode (auto-allow tools by default) - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'opencode', - openCodeConfig: { autoAllowTools: true }, - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start OpenCode'); - - // Switch to the new session (don't pre-set activeSessionId — selectSession - // early-returns when IDs match, skipping buffer load and sendResize) - if (data.sessionId) { - await this.selectSession(data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Directory Input - // ═══════════════════════════════════════════════════════════════ - - toggleDirInput() { - const btn = document.querySelector('#dirDisplay').parentElement; - const input = document.getElementById('dirInput'); - - if (input.classList.contains('hidden')) { - input.classList.remove('hidden'); - btn.style.display = 'none'; - input.focus(); - } - } - - hideDirInput() { - const btn = document.querySelector('#dirDisplay').parentElement; - const input = document.getElementById('dirInput'); - - setTimeout(() => { - input.classList.add('hidden'); - btn.style.display = ''; - - const value = input.value.trim(); - document.getElementById('dirDisplay').textContent = value || 'No directory'; - }, 100); - } - - // ═══════════════════════════════════════════════════════════════ - // Respawn Banner - // ═══════════════════════════════════════════════════════════════ - - showRespawnBanner() { - this.$('respawnBanner').style.display = 'flex'; - // Also show timer if there's a timed respawn - if (this.activeSessionId && this.respawnTimers[this.activeSessionId]) { - this.showRespawnTimer(); - } - // Show tokens if session has token data - const session = this.sessions.get(this.activeSessionId); - if (session && session.tokens) { - this.updateRespawnTokens(session.tokens); - } - } - - hideRespawnBanner() { - this.$('respawnBanner').style.display = 'none'; - this.hideRespawnTimer(); - } - - // Human-friendly state labels - getStateLabel(state) { - const labels = { - 'stopped': 'Stopped', - 'watching': 'Watching', - 'confirming_idle': 'Confirming idle', - 'ai_checking': 'AI checking', - 'sending_update': 'Sending prompt', - 'waiting_update': 'Running prompt', - 'sending_clear': 'Clearing context', - 'waiting_clear': 'Clearing...', - 'sending_init': 'Initializing', - 'waiting_init': 'Initializing...', - 'monitoring_init': 'Waiting for work', - 'sending_kickstart': 'Kickstarting', - 'waiting_kickstart': 'Kickstarting...', - }; - return labels[state] || state.replace(/_/g, ' '); - } - - updateRespawnBanner(state) { - const stateEl = this.$('respawnState'); - stateEl.textContent = this.getStateLabel(state); - // Clear blocked state when state changes (resumed from blocked) - stateEl.classList.remove('respawn-blocked'); - } - - updateDetectionDisplay(detection) { - if (!detection) return; - - const statusEl = this.$('detectionStatus'); - const waitingEl = this.$('detectionWaiting'); - const confidenceEl = this.$('detectionConfidence'); - const aiCheckEl = document.getElementById('detectionAiCheck'); - const hookEl = document.getElementById('detectionHook'); - - // Hook-based detection indicator (highest priority signals) - if (hookEl) { - if (detection.stopHookReceived || detection.idlePromptReceived) { - const hookType = detection.idlePromptReceived ? 'idle' : 'stop'; - hookEl.textContent = `🎯 ${hookType} hook`; - hookEl.className = 'detection-hook hook-active'; - hookEl.style.display = ''; - } else { - hookEl.style.display = 'none'; - } - } - - // Simplified status - only show when meaningful - if (detection.statusText && detection.statusText !== 'Watching...') { - statusEl.textContent = detection.statusText; - statusEl.style.display = ''; - } else { - statusEl.style.display = 'none'; - } - - // Hide "waiting for" text - it's redundant with the state label - waitingEl.style.display = 'none'; - - // Show confidence only when confirming (>0%) - const confidence = detection.confidenceLevel || 0; - if (confidence > 0) { - confidenceEl.textContent = `${confidence}%`; - confidenceEl.style.display = ''; - confidenceEl.className = 'detection-confidence'; - // Hook signals give 100% confidence - if (detection.stopHookReceived || detection.idlePromptReceived) { - confidenceEl.classList.add('hook-confirmed'); - } else if (confidence >= 60) { - confidenceEl.classList.add('high'); - } else if (confidence >= 30) { - confidenceEl.classList.add('medium'); - } - } else { - confidenceEl.style.display = 'none'; - } - - // AI check display - compact format - if (aiCheckEl && detection.aiCheck) { - const ai = detection.aiCheck; - let aiText = ''; - let aiClass = 'detection-ai-check'; - - if (ai.status === 'checking') { - aiText = '🔍 AI checking...'; - aiClass += ' ai-checking'; - } else if (ai.status === 'cooldown' && ai.cooldownEndsAt) { - const remaining = Math.ceil((ai.cooldownEndsAt - Date.now()) / 1000); - if (remaining > 0) { - if (ai.lastVerdict === 'WORKING') { - aiText = `⏳ Working, retry ${remaining}s`; - aiClass += ' ai-working'; - } else { - aiText = `✓ Idle, wait ${remaining}s`; - aiClass += ' ai-idle'; - } - } - } else if (ai.status === 'disabled') { - aiText = '⚠ AI disabled'; - aiClass += ' ai-disabled'; - } else if (ai.lastVerdict && ai.lastCheckTime) { - const ago = Math.round((Date.now() - ai.lastCheckTime) / 1000); - if (ago < 120) { - aiText = ai.lastVerdict === 'IDLE' - ? `✓ Idle (${ago}s)` - : `⏳ Working (${ago}s)`; - aiClass += ai.lastVerdict === 'IDLE' ? ' ai-idle' : ' ai-working'; - } - } - - aiCheckEl.textContent = aiText; - aiCheckEl.className = aiClass; - aiCheckEl.style.display = aiText ? '' : 'none'; - } else if (aiCheckEl) { - aiCheckEl.style.display = 'none'; - } - - // Manage row2 visibility - hide if nothing visible - const row2 = this.$('respawnStatusRow2'); - if (row2) { - const hasVisibleContent = - (hookEl && hookEl.style.display !== 'none') || - (aiCheckEl && aiCheckEl.style.display !== 'none') || - (statusEl && statusEl.style.display !== 'none') || - (this.respawnCountdownTimers[this.activeSessionId] && - Object.keys(this.respawnCountdownTimers[this.activeSessionId]).length > 0); - row2.style.display = hasVisibleContent ? '' : 'none'; - } - } - - showRespawnTimer() { - const timerEl = this.$('respawnTimer'); - timerEl.style.display = ''; - this.updateRespawnTimer(); - // Update every second - if (this.respawnTimerInterval) clearInterval(this.respawnTimerInterval); - this.respawnTimerInterval = setInterval(() => this.updateRespawnTimer(), 1000); - } - - hideRespawnTimer() { - this.$('respawnTimer').style.display = 'none'; - if (this.respawnTimerInterval) { - clearInterval(this.respawnTimerInterval); - this.respawnTimerInterval = null; - } - } - - updateRespawnTimer() { - if (!this.activeSessionId || !this.respawnTimers[this.activeSessionId]) { - this.hideRespawnTimer(); - return; - } - - const timer = this.respawnTimers[this.activeSessionId]; - // Guard against invalid timer data - if (!timer.endAt || isNaN(timer.endAt)) { - this.hideRespawnTimer(); - return; - } - - const now = Date.now(); - const remaining = Math.max(0, timer.endAt - now); - - if (remaining <= 0) { - this.$('respawnTimer').textContent = 'Time up'; - delete this.respawnTimers[this.activeSessionId]; - this.hideRespawnTimer(); - return; - } - - this.$('respawnTimer').textContent = this.formatTime(remaining); - } - - updateRespawnTokens(tokens) { - // Skip if tokens haven't changed (avoid unnecessary DOM writes) - const isObject = tokens && typeof tokens === 'object'; - const total = isObject ? tokens.total : tokens; - if (total === this._lastRespawnTokenTotal) return; - this._lastRespawnTokenTotal = total; - - const tokensEl = this.$('respawnTokens'); - const input = isObject ? (tokens.input || 0) : Math.round(total * 0.6); - const output = isObject ? (tokens.output || 0) : Math.round(total * 0.4); - - if (total > 0) { - tokensEl.style.display = ''; - const tokenStr = this.formatTokens(total); - const settings = this.loadAppSettingsFromStorage(); - const showCost = settings.showCost ?? false; - if (showCost) { - const estimatedCost = this.estimateCost(input, output); - tokensEl.textContent = `${tokenStr} tokens · $${estimatedCost.toFixed(2)}`; - } else { - tokensEl.textContent = `${tokenStr} tokens`; - } - } else { - tokensEl.style.display = 'none'; - } - - // Also update mobile CLI info bar (shows tokens on mobile) - this.updateCliInfoDisplay(); - } - - // Update CLI info display (tokens, version, model - shown on mobile) - updateCliInfoDisplay() { - const infoBar = this.$('cliInfoBar'); - if (!infoBar) return; - - const session = this.sessions.get(this.activeSessionId); - if (!session) { - infoBar.style.display = 'none'; - return; - } - - // Build display parts - tokens first (most important on mobile) - let parts = []; - - // Add tokens if available - if (session.tokens) { - const total = typeof session.tokens === 'object' ? session.tokens.total : session.tokens; - if (total > 0) { - parts.push(`${this.formatTokens(total)} tokens`); - } - } - - // Add model (condensed) - if (session.cliModel) { - // Shorten model names for mobile: "claude-sonnet-4-20250514" -> "Sonnet 4" - let model = session.cliModel; - if (model.includes('opus')) model = 'Opus'; - else if (model.includes('sonnet')) model = 'Sonnet'; - else if (model.includes('haiku')) model = 'Haiku'; - parts.push(model); - } - - // Add version (compact format) - if (session.cliVersion) { - // Show "v2.1.27" or "v2.1.27 ↑" if update available - let versionStr = `v${session.cliVersion}`; - if (session.cliLatestVersion && session.cliLatestVersion !== session.cliVersion) { - versionStr += ' ↑'; // Arrow indicates update available - } - parts.push(versionStr); - } - - if (parts.length > 0) { - infoBar.textContent = parts.join(' · '); - infoBar.style.display = ''; - } else { - infoBar.style.display = 'none'; - } - } - - // ═══════════════════════════════════════════════════════════════ - // Countdown Timer Display Methods - // ═══════════════════════════════════════════════════════════════ - - addActionLogEntry(sessionId, action) { - // Only keep truly interesting events - no spam - // KEEP: command (inputs), hook events, AI verdicts, plan verdicts - // SKIP: timer, timer-cancel, state changes, routine detection, step confirmations - - const interestingTypes = ['command', 'hook']; - - // Always keep commands and hooks - if (interestingTypes.includes(action.type)) { - // ok, keep it - } - // AI check: only verdicts (IDLE, WORKING) and errors, not "Spawning" - else if (action.type === 'ai-check') { - if (action.detail.includes('Spawning')) return; - } - // Plan check: only verdicts, not "Spawning" - else if (action.type === 'plan-check') { - if (action.detail.includes('Spawning')) return; - } - // Transcript: keep completion/plan detection - else if (action.type === 'transcript') { - // keep it - } - // Skip everything else (timer, timer-cancel, state, detection, step) - else { - return; - } - - if (!this.respawnActionLogs[sessionId]) { - this.respawnActionLogs[sessionId] = []; - } - this.respawnActionLogs[sessionId].unshift(action); - // Keep reasonable history - if (this.respawnActionLogs[sessionId].length > 30) { - this.respawnActionLogs[sessionId].pop(); - } - } - - startCountdownInterval() { - if (this.timerCountdownInterval) return; - this.timerCountdownInterval = setInterval(() => { - if (this.activeSessionId && this.respawnCountdownTimers[this.activeSessionId]) { - this.updateCountdownTimerDisplay(); - } - }, 100); - } - - stopCountdownInterval() { - if (this.timerCountdownInterval) { - clearInterval(this.timerCountdownInterval); - this.timerCountdownInterval = null; - } - } - - updateCountdownTimerDisplay() { - const timersContainer = this.$('respawnCountdownTimers'); - const row2 = this.$('respawnStatusRow2'); - if (!timersContainer) return; - - const timers = this.respawnCountdownTimers[this.activeSessionId]; - const hasTimers = timers && Object.keys(timers).length > 0; - - if (!hasTimers) { - timersContainer.innerHTML = ''; - // Update row2 visibility - if (row2) { - const hookEl = document.getElementById('detectionHook'); - const aiCheckEl = document.getElementById('detectionAiCheck'); - const statusEl = this.$('detectionStatus'); - const hasVisibleContent = - (hookEl && hookEl.style.display !== 'none') || - (aiCheckEl && aiCheckEl.style.display !== 'none') || - (statusEl && statusEl.style.display !== 'none'); - row2.style.display = hasVisibleContent ? '' : 'none'; - } - return; - } - - // Show row2 since we have timers - if (row2) row2.style.display = ''; - - const now = Date.now(); - let html = ''; - - for (const [name, timer] of Object.entries(timers)) { - const remainingMs = Math.max(0, timer.endsAt - now); - const remainingSec = (remainingMs / 1000).toFixed(1); - const percent = Math.max(0, Math.min(100, (remainingMs / timer.totalMs) * 100)); - - // Shorter timer name display - const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase()); - - html += `
- ${escapeHtml(displayName)} - ${remainingSec}s -
-
-
-
`; - } - - timersContainer.innerHTML = html; - } - - updateActionLogDisplay() { - const logContainer = this.$('respawnActionLog'); - if (!logContainer) return; - - const actions = this.respawnActionLogs[this.activeSessionId]; - if (!actions || actions.length === 0) { - logContainer.innerHTML = ''; - return; - } - - let html = ''; - // Show fewer entries for compact view - for (const action of actions.slice(0, 5)) { - const time = new Date(action.timestamp).toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - const isCommand = action.type === 'command'; - const extraClass = isCommand ? ' action-command' : ''; - // Compact format: time [type] detail - html += `
- ${time} - [${action.type}] - ${escapeHtml(action.detail)} -
`; - } - - logContainer.innerHTML = html; - } - - clearCountdownTimers(sessionId) { - delete this.respawnCountdownTimers[sessionId]; - delete this.respawnActionLogs[sessionId]; - if (sessionId === this.activeSessionId) { - this.updateCountdownTimerDisplay(); - this.updateActionLogDisplay(); - } - } - - async stopRespawn() { - if (!this.activeSessionId) return; - try { - await this._apiPost(`/api/sessions/${this.activeSessionId}/respawn/stop`, {}); - delete this.respawnTimers[this.activeSessionId]; - this.clearCountdownTimers(this.activeSessionId); - } catch (err) { - this.showToast('Failed to stop respawn', 'error'); - } - } - // ═══════════════════════════════════════════════════════════════ // Kill Sessions // ═══════════════════════════════════════════════════════════════ @@ -5291,170 +2488,6 @@ class CodemanApp { } } - // ═══════════════════════════════════════════════════════════════ - // Terminal Controls - // ═══════════════════════════════════════════════════════════════ - - clearTerminal() { - this.terminal.clear(); - } - - /** - * Restore terminal size to match web UI dimensions. - * Use this after mobile screen attachment has squeezed the terminal. - * Sends resize to PTY and Ctrl+L to trigger Claude to redraw. - */ - async restoreTerminalSize() { - if (!this.activeSessionId) { - this.showToast('No active session', 'warning'); - return; - } - - const dims = this.getTerminalDimensions(); - if (!dims) { - this.showToast('Could not determine terminal size', 'error'); - return; - } - - try { - // Send resize to restore proper dimensions (with minimum enforcement) - await this.sendResize(this.activeSessionId); - - // Send Ctrl+L to trigger Claude to redraw at new size - await fetch(`/api/sessions/${this.activeSessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: '\x0c' }) - }); - - this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success'); - } catch (err) { - console.error('Failed to restore terminal size:', err); - this.showToast('Failed to restore terminal size', 'error'); - } - } - - // Send Ctrl+L to fix display for newly created sessions once Claude is running - sendPendingCtrlL(sessionId) { - if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) { - return; - } - this.pendingCtrlL.delete(sessionId); - - // Only send if this is the active session - if (sessionId !== this.activeSessionId) { - return; - } - - // Send resize + Ctrl+L to fix the display (with minimum dimension enforcement) - this.sendResize(sessionId).then(() => { - fetch(`/api/sessions/${sessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: '\x0c' }) - }); - }); - } - - async copyTerminal() { - try { - const buffer = this.terminal.buffer.active; - let text = ''; - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - if (line) text += line.translateToString(true) + '\n'; - } - await navigator.clipboard.writeText(text.replace(/\n+$/, '\n')); - this.showToast('Copied to clipboard', 'success'); - } catch (err) { - this.showToast('Failed to copy', 'error'); - } - } - - increaseFontSize() { - const current = this.terminal.options.fontSize || 14; - this.setFontSize(Math.min(current + 2, 24)); - } - - decreaseFontSize() { - const current = this.terminal.options.fontSize || 14; - this.setFontSize(Math.max(current - 2, 10)); - } - - setFontSize(size) { - this.terminal.options.fontSize = size; - document.getElementById('fontSizeDisplay').textContent = size; - this.fitAddon.fit(); - localStorage.setItem('codeman-font-size', size); - // Update overlay font cache and re-render at new cell dimensions - this._localEchoOverlay?.refreshFont(); - } - - loadFontSize() { - const saved = localStorage.getItem('codeman-font-size'); - if (saved) { - const size = parseInt(saved, 10); - if (size >= 10 && size <= 24) { - this.terminal.options.fontSize = size; - document.getElementById('fontSizeDisplay').textContent = size; - } - } - } - - /** - * Get terminal dimensions with minimum enforcement. - * Prevents extremely narrow terminals that cause vertical text wrapping. - * @returns {{cols: number, rows: number}|null} - */ - getTerminalDimensions() { - const MIN_COLS = 40; - const MIN_ROWS = 10; - const dims = this.fitAddon?.proposeDimensions(); - if (!dims) return null; - return { - cols: Math.max(dims.cols, MIN_COLS), - rows: Math.max(dims.rows, MIN_ROWS) - }; - } - - /** - * Send resize to a session with minimum dimension enforcement. - * @param {string} sessionId - * @returns {Promise} - */ - async sendResize(sessionId) { - const dims = this.getTerminalDimensions(); - if (!dims) return; - // Fast path: WebSocket resize - if (this._wsReady && this._wsSessionId === sessionId) { - try { - this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows })); - return; - } catch { - // Fall through to HTTP POST - } - } - await fetch(`/api/sessions/${sessionId}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(dims) - }); - } - - /** - * Send input to the active session. - * @param {string} input - Text to send (include \r for Enter) - * @returns {Promise} - */ - async sendInput(input) { - if (!this.activeSessionId) return; - await fetch(`/api/sessions/${this.activeSessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input, useMux: true }) - }); - } - // ═══════════════════════════════════════════════════════════════ // Timer // ═══════════════════════════════════════════════════════════════ @@ -5561,7089 +2594,6 @@ class CodemanApp { } } - // ═══════════════════════════════════════════════════════════════ - // Session Options Modal - // ═══════════════════════════════════════════════════════════════ - - openSessionOptions(sessionId) { - const session = this.sessions.get(sessionId); - if (!session) return; - - this.editingSessionId = sessionId; - - // Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only) - this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn'); - - // Update respawn status display and buttons - const respawnStatus = document.getElementById('sessionRespawnStatus'); - const enableBtn = document.getElementById('modalEnableRespawnBtn'); - const stopBtn = document.getElementById('modalStopRespawnBtn'); - - if (this.respawnStatus[sessionId]) { - respawnStatus.classList.add('active'); - respawnStatus.querySelector('.respawn-status-text').textContent = - this.respawnStatus[sessionId].state || 'Active'; - enableBtn.style.display = 'none'; - stopBtn.style.display = ''; - } else { - respawnStatus.classList.remove('active'); - respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active'; - enableBtn.style.display = ''; - stopBtn.style.display = 'none'; - } - - // Only show respawn section for claude mode sessions with a running process - const respawnSection = document.getElementById('sessionRespawnSection'); - if (session.mode === 'claude' && session.pid) { - respawnSection.style.display = ''; - } else { - respawnSection.style.display = 'none'; - } - - // Hide Claude-specific options for OpenCode sessions - const isOpenCode = session.mode === 'opencode'; - const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); - claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; }); - - // Reset duration presets to default (unlimited) - this.selectDurationPreset(''); - - // Populate respawn config from saved state - this.loadSavedRespawnConfig(sessionId); - - // Populate auto-compact/clear from session state - document.getElementById('modalAutoCompactEnabled').checked = session.autoCompactEnabled ?? false; - document.getElementById('modalAutoCompactThreshold').value = session.autoCompactThreshold ?? 110000; - document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? ''; - document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false; - document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000; - document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; - document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; - - // Populate session name input - document.getElementById('modalSessionName').value = session.name || ''; - - // Initialize color picker with current session color - const currentColor = session.color || 'default'; - const colorPicker = document.getElementById('sessionColorPicker'); - colorPicker?.querySelectorAll('.color-swatch').forEach(s => { - s.classList.toggle('selected', s.dataset.color === currentColor); - }); - - // Initialize respawn preset dropdown - this.renderPresetDropdown(); - document.getElementById('respawnPresetSelect').value = ''; - document.getElementById('presetDescriptionHint').textContent = ''; - - // Hide Ralph/Todo tab and Respawn tab for opencode sessions (not supported) - const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]'); - const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]'); - if (isOpenCode) { - if (ralphTabBtn) ralphTabBtn.style.display = 'none'; - if (respawnTabBtn) respawnTabBtn.style.display = 'none'; - // Default to Context tab for opencode sessions since Respawn is hidden - this.switchOptionsTab('context'); - } else { - if (ralphTabBtn) ralphTabBtn.style.display = ''; - if (respawnTabBtn) respawnTabBtn.style.display = ''; - } - - // Populate Ralph Wiggum form with current session values (skip for opencode) - if (!isOpenCode) { - const ralphState = this.ralphStates.get(sessionId); - this.populateRalphForm({ - enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false, - completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '', - maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0, - }); - } - - const modal = document.getElementById('sessionOptionsModal'); - modal.classList.add('active'); - - // Activate focus trap - this.activeFocusTrap = new FocusTrap(modal); - this.activeFocusTrap.activate(); - } - - async saveSessionName() { - if (!this.editingSessionId) return; - const name = document.getElementById('modalSessionName').value.trim(); - try { - await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name }); - } catch (err) { - this.showToast('Failed to save session name: ' + err.message, 'error'); - } - } - - async autoSaveAutoCompact() { - if (!this.editingSessionId) return; - try { - await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, { - enabled: document.getElementById('modalAutoCompactEnabled').checked, - threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000, - prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined - }); - } catch { /* silent */ } - } - - async autoSaveAutoClear() { - if (!this.editingSessionId) return; - try { - await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, { - enabled: document.getElementById('modalAutoClearEnabled').checked, - threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000 - }); - } catch { /* silent */ } - } - - async toggleSessionImageWatcher() { - if (!this.editingSessionId) return; - const enabled = document.getElementById('modalImageWatcherEnabled').checked; - try { - await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled }); - // Update local session state - const session = this.sessions.get(this.editingSessionId); - if (session) { - session.imageWatcherEnabled = enabled; - } - this.showToast(`Image watcher ${enabled ? 'enabled' : 'disabled'}`, 'success'); - } catch (err) { - this.showToast('Failed to toggle image watcher', 'error'); - } - } - - async toggleFlickerFilter() { - if (!this.editingSessionId) return; - const enabled = document.getElementById('modalFlickerFilterEnabled').checked; - try { - await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled }); - // Update local session state - const session = this.sessions.get(this.editingSessionId); - if (session) { - session.flickerFilterEnabled = enabled; - } - this.showToast(`Flicker filter ${enabled ? 'enabled' : 'disabled'}`, 'success'); - } catch (err) { - this.showToast('Failed to toggle flicker filter', 'error'); - } - } - - async autoSaveRespawnConfig() { - if (!this.editingSessionId) return; - const config = { - updatePrompt: document.getElementById('modalRespawnPrompt').value, - sendClear: document.getElementById('modalRespawnSendClear').checked, - sendInit: document.getElementById('modalRespawnSendInit').checked, - kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined, - autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked, - }; - try { - await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config); - } catch { - // Silent save - don't interrupt user - } - } - - async loadSavedRespawnConfig(sessionId) { - try { - const res = await fetch(`/api/sessions/${sessionId}/respawn/config`); - const data = await res.json(); - if (data.success && data.config) { - const c = data.config; - document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md'; - document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true; - document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true; - document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || ''; - document.getElementById('modalRespawnAutoAccept').checked = c.autoAcceptPrompts ?? true; - // Restore duration if set - if (c.durationMinutes) { - const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`); - if (presetBtn) { - this.selectDurationPreset(String(c.durationMinutes)); - } else { - this.selectDurationPreset('custom'); - document.getElementById('modalRespawnDuration').value = c.durationMinutes; - } - } - } - } catch { - // Ignore - use defaults - } - } - - // Handle duration preset selection - selectDurationPreset(value) { - // Remove active from all buttons - document.querySelectorAll('.duration-preset-btn').forEach(btn => btn.classList.remove('active')); - - // Find and activate the clicked button - const btn = document.querySelector(`.duration-preset-btn[data-minutes="${value}"]`); - if (btn) btn.classList.add('active'); - - // Show/hide custom input - const customInput = document.querySelector('.duration-custom-input'); - const durationInput = document.getElementById('modalRespawnDuration'); - - if (value === 'custom') { - customInput.classList.add('visible'); - durationInput.focus(); - } else { - customInput.classList.remove('visible'); - durationInput.value = ''; // Clear custom value when using preset - } - } - - // Get selected duration from preset buttons or custom input - getSelectedDuration() { - const customInput = document.querySelector('.duration-custom-input'); - const durationInput = document.getElementById('modalRespawnDuration'); - - if (customInput.classList.contains('visible')) { - // Custom mode - use input value - return durationInput.value ? parseInt(durationInput.value) : null; - } else { - // Preset mode - get from active button - const activeBtn = document.querySelector('.duration-preset-btn.active'); - const minutes = activeBtn?.dataset.minutes; - return minutes ? parseInt(minutes) : null; - } - } - - // ═══════════════════════════════════════════════════════════════ - // Respawn Presets - // ═══════════════════════════════════════════════════════════════ - - loadRespawnPresets() { - // Custom presets: prefer server-synced cache, fall back to legacy localStorage key - const serverCache = this._serverRespawnPresets; - if (serverCache) return [...BUILTIN_RESPAWN_PRESETS, ...serverCache]; - const saved = localStorage.getItem('codeman-respawn-presets'); - const custom = saved ? JSON.parse(saved) : []; - return [...BUILTIN_RESPAWN_PRESETS, ...custom]; - } - - saveRespawnPresets(presets) { - // Only save custom presets (not built-in) - const custom = presets.filter(p => !p.builtIn); - // Update local cache + legacy localStorage - this._serverRespawnPresets = custom; - localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom)); - // Persist to server (cross-device sync) - this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {}); - } - - renderPresetDropdown() { - const presets = this.loadRespawnPresets(); - const builtinGroup = document.getElementById('builtinPresetsGroup'); - const customGroup = document.getElementById('customPresetsGroup'); - - if (!builtinGroup || !customGroup) return; - - // Clear and repopulate - builtinGroup.innerHTML = ''; - customGroup.innerHTML = ''; - - presets.forEach(preset => { - const option = document.createElement('option'); - option.value = preset.id; - option.textContent = preset.name; - if (preset.builtIn) { - builtinGroup.appendChild(option); - } else { - customGroup.appendChild(option); - } - }); - } - - updatePresetDescription() { - const select = document.getElementById('respawnPresetSelect'); - const hint = document.getElementById('presetDescriptionHint'); - if (!select || !hint) return; - - const presetId = select.value; - if (!presetId) { - hint.textContent = ''; - return; - } - - const presets = this.loadRespawnPresets(); - const preset = presets.find(p => p.id === presetId); - hint.textContent = preset?.description || ''; - } - - loadRespawnPreset() { - const select = document.getElementById('respawnPresetSelect'); - const presetId = select?.value; - if (!presetId) { - this.showToast('Please select a preset first', 'warning'); - return; - } - - const presets = this.loadRespawnPresets(); - const preset = presets.find(p => p.id === presetId); - if (!preset) return; - - // Populate form fields - document.getElementById('modalRespawnPrompt').value = preset.config.updatePrompt || ''; - document.getElementById('modalRespawnSendClear').checked = preset.config.sendClear ?? false; - document.getElementById('modalRespawnSendInit').checked = preset.config.sendInit ?? false; - document.getElementById('modalRespawnKickstart').value = preset.config.kickstartPrompt || ''; - document.getElementById('modalRespawnAutoAccept').checked = preset.config.autoAcceptPrompts ?? true; - - // Set duration if available - if (preset.durationMinutes) { - this.selectDurationPreset(String(preset.durationMinutes)); - } - - // Reset select to placeholder - select.value = ''; - document.getElementById('presetDescriptionHint').textContent = ''; - - this.showToast(`Loaded preset: ${preset.name}`, 'info'); - } - - saveCurrentAsPreset() { - document.getElementById('savePresetModal').classList.add('active'); - document.getElementById('presetNameInput').value = ''; - document.getElementById('presetDescriptionInput').value = ''; - document.getElementById('presetNameInput').focus(); - } - - closeSavePresetModal() { - document.getElementById('savePresetModal').classList.remove('active'); - } - - confirmSavePreset() { - const name = document.getElementById('presetNameInput').value.trim(); - if (!name) { - this.showToast('Please enter a preset name', 'error'); - return; - } - - // Get current config from form - const updatePrompt = document.getElementById('modalRespawnPrompt').value; - const sendClear = document.getElementById('modalRespawnSendClear').checked; - const sendInit = document.getElementById('modalRespawnSendInit').checked; - const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined; - const durationMinutes = this.getSelectedDuration(); - - const newPreset = { - id: 'custom-' + Date.now(), - name, - description: document.getElementById('presetDescriptionInput').value.trim() || undefined, - config: { - idleTimeoutMs: 5000, // Default - updatePrompt, - interStepDelayMs: 3000, // Default - sendClear, - sendInit, - kickstartPrompt, - }, - durationMinutes: durationMinutes || undefined, - builtIn: false, - createdAt: Date.now(), - }; - - const presets = this.loadRespawnPresets(); - presets.push(newPreset); - this.saveRespawnPresets(presets); - this.renderPresetDropdown(); - this.closeSavePresetModal(); - this.showToast(`Saved preset: ${name}`, 'success'); - } - - deletePreset(presetId) { - const presets = this.loadRespawnPresets(); - const preset = presets.find(p => p.id === presetId); - if (!preset || preset.builtIn) { - this.showToast('Cannot delete built-in presets', 'warning'); - return; - } - - const filtered = presets.filter(p => p.id !== presetId); - this.saveRespawnPresets(filtered); - this.renderPresetDropdown(); - this.showToast(`Deleted preset: ${preset.name}`, 'success'); - } - - // Get respawn config from modal inputs - getModalRespawnConfig() { - const updatePrompt = document.getElementById('modalRespawnPrompt').value; - const sendClear = document.getElementById('modalRespawnSendClear').checked; - const sendInit = document.getElementById('modalRespawnSendInit').checked; - const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined; - const autoAcceptPrompts = document.getElementById('modalRespawnAutoAccept').checked; - const durationMinutes = this.getSelectedDuration(); - - // Auto-compact settings - const autoCompactEnabled = document.getElementById('modalAutoCompactEnabled').checked; - const autoCompactThreshold = parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000; - const autoCompactPrompt = document.getElementById('modalAutoCompactPrompt').value.trim() || undefined; - - // Auto-clear settings - const autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked; - const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000; - - return { - respawnConfig: { - enabled: true, // Fix: ensure enabled is set so pre-saved configs with enabled: false get overridden - updatePrompt, - sendClear, - sendInit, - kickstartPrompt, - autoAcceptPrompts, - }, - durationMinutes, - autoCompactEnabled, - autoCompactThreshold, - autoCompactPrompt, - autoClearEnabled, - autoClearThreshold - }; - } - - async enableRespawnFromModal() { - if (!this.editingSessionId) { - this.showToast('No session selected', 'warning'); - return; - } - - const { - respawnConfig, - durationMinutes, - autoCompactEnabled, - autoCompactThreshold, - autoCompactPrompt, - autoClearEnabled, - autoClearThreshold - } = this.getModalRespawnConfig(); - - try { - // Enable respawn on the session - const res = await fetch(`/api/sessions/${this.editingSessionId}/respawn/enable`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config: respawnConfig, durationMinutes }) - }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - - // Set auto-compact if enabled - if (autoCompactEnabled) { - await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: true, threshold: autoCompactThreshold, prompt: autoCompactPrompt }) - }); - } - - // Set auto-clear if enabled - if (autoClearEnabled) { - await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: true, threshold: autoClearThreshold }) - }); - } - - // Update UI - const respawnStatus = document.getElementById('sessionRespawnStatus'); - respawnStatus.classList.add('active'); - respawnStatus.querySelector('.respawn-status-text').textContent = 'WATCHING'; - document.getElementById('modalEnableRespawnBtn').style.display = 'none'; - document.getElementById('modalStopRespawnBtn').style.display = ''; - - this.showToast('Respawn enabled', 'success'); - } catch (err) { - this.showToast('Failed to enable respawn: ' + err.message, 'error'); - } - } - - async stopRespawnFromModal() { - if (!this.editingSessionId) return; - try { - await fetch(`/api/sessions/${this.editingSessionId}/respawn/stop`, { method: 'POST' }); - delete this.respawnTimers[this.editingSessionId]; - - // Update the modal display - const respawnStatus = document.getElementById('sessionRespawnStatus'); - respawnStatus.classList.remove('active'); - respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active'; - document.getElementById('modalEnableRespawnBtn').style.display = ''; - document.getElementById('modalStopRespawnBtn').style.display = 'none'; - - this.showToast('Respawn stopped', 'success'); - } catch (err) { - this.showToast('Failed to stop respawn', 'error'); - } - } - - closeSessionOptions() { - this.editingSessionId = null; - // Stop run summary auto-refresh if it was running - this.stopRunSummaryAutoRefresh(); - document.getElementById('sessionOptionsModal').classList.remove('active'); - - // Deactivate focus trap and restore focus - if (this.activeFocusTrap) { - this.activeFocusTrap.deactivate(); - this.activeFocusTrap = null; - } - } - - setupColorPicker() { - const picker = document.getElementById('sessionColorPicker'); - if (!picker) return; - - picker.addEventListener('click', (e) => { - const swatch = e.target.closest('.color-swatch'); - if (!swatch || !this.editingSessionId) return; - - const color = swatch.dataset.color; - this.setSessionColor(this.editingSessionId, color); - }); - } - - async setSessionColor(sessionId, color) { - try { - const res = await fetch(`/api/sessions/${sessionId}/color`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ color }) - }); - - if (res.ok) { - const session = this.sessions.get(sessionId); - if (session) { - session.color = color; - this.renderSessionTabs(); - } - - // Update picker UI to show selection - const picker = document.getElementById('sessionColorPicker'); - if (picker) { - picker.querySelectorAll('.color-swatch').forEach(swatch => { - swatch.classList.toggle('selected', swatch.dataset.color === color); - }); - } - } else { - this.showToast('Failed to set session color', 'error'); - } - } catch (err) { - this.showToast('Failed to set session color', 'error'); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Run Summary Modal - // ═══════════════════════════════════════════════════════════════ - - async openRunSummary(sessionId) { - // Open session options modal and switch to summary tab - this.openSessionOptions(sessionId); - this.switchOptionsTab('summary'); - - this.runSummarySessionId = sessionId; - this.runSummaryFilter = 'all'; - - // Reset filter buttons - document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.filter === 'all'); - }); - - // Load summary data - await this.loadRunSummary(sessionId); - } - - closeRunSummary() { - this.runSummarySessionId = null; - this.stopRunSummaryAutoRefresh(); - // Close session options modal (summary is now a tab in it) - this.closeSessionOptions(); - } - - async refreshRunSummary() { - const sessionId = this.runSummarySessionId || this.editingSessionId; - if (!sessionId) return; - await this.loadRunSummary(sessionId); - } - - toggleRunSummaryAutoRefresh() { - const checkbox = document.getElementById('runSummaryAutoRefresh'); - if (checkbox.checked) { - this.startRunSummaryAutoRefresh(); - } else { - this.stopRunSummaryAutoRefresh(); - } - } - - startRunSummaryAutoRefresh() { - if (this.runSummaryAutoRefreshTimer) return; - this.runSummaryAutoRefreshTimer = setInterval(() => { - if (this.runSummarySessionId) { - this.loadRunSummary(this.runSummarySessionId); - } - }, 5000); // Refresh every 5 seconds - } - - stopRunSummaryAutoRefresh() { - if (this.runSummaryAutoRefreshTimer) { - clearInterval(this.runSummaryAutoRefreshTimer); - this.runSummaryAutoRefreshTimer = null; - } - const checkbox = document.getElementById('runSummaryAutoRefresh'); - if (checkbox) checkbox.checked = false; - } - - exportRunSummary(format) { - if (!this.runSummaryData) { - this.showToast('No summary data to export', 'error'); - return; - } - - const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData; - const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); - const filename = `run-summary-${sessionName || 'session'}-${timestamp}`; - - if (format === 'json') { - const json = JSON.stringify(this.runSummaryData, null, 2); - this.downloadFile(`${filename}.json`, json, 'application/json'); - } else if (format === 'md') { - const duration = lastUpdatedAt - startedAt; - let md = `# Run Summary: ${sessionName || 'Session'}\n\n`; - md += `**Duration**: ${this.formatDuration(duration)}\n`; - md += `**Started**: ${new Date(startedAt).toLocaleString()}\n`; - md += `**Last Update**: ${new Date(lastUpdatedAt).toLocaleString()}\n\n`; - - md += `## Statistics\n\n`; - md += `| Metric | Value |\n`; - md += `|--------|-------|\n`; - md += `| Respawn Cycles | ${stats.totalRespawnCycles} |\n`; - md += `| Peak Tokens | ${this.formatTokens(stats.peakTokens)} |\n`; - md += `| Active Time | ${this.formatDuration(stats.totalTimeActiveMs)} |\n`; - md += `| Idle Time | ${this.formatDuration(stats.totalTimeIdleMs)} |\n`; - md += `| Errors | ${stats.errorCount} |\n`; - md += `| Warnings | ${stats.warningCount} |\n`; - md += `| AI Checks | ${stats.aiCheckCount} |\n`; - md += `| State Transitions | ${stats.stateTransitions} |\n\n`; - - md += `## Event Timeline\n\n`; - if (events.length === 0) { - md += `No events recorded.\n`; - } else { - md += `| Time | Type | Severity | Title | Details |\n`; - md += `|------|------|----------|-------|----------|\n`; - for (const event of events) { - const time = new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false }); - const details = event.details ? event.details.replace(/\|/g, '\\|') : '-'; - md += `| ${time} | ${event.type} | ${event.severity} | ${event.title} | ${details} |\n`; - } - } - - this.downloadFile(`${filename}.md`, md, 'text/markdown'); - } - - this.showToast(`Exported as ${format.toUpperCase()}`, 'success'); - } - - downloadFile(filename, content, mimeType) { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - async loadRunSummary(sessionId) { - const timeline = document.getElementById('runSummaryTimeline'); - timeline.innerHTML = '

Loading summary...

'; - - try { - const response = await fetch(`/api/sessions/${sessionId}/run-summary`); - const data = await response.json(); - - if (!data.success) { - timeline.innerHTML = `

Failed to load summary: ${escapeHtml(data.error)}

`; - return; - } - - this.runSummaryData = data.summary; - this.renderRunSummary(); - } catch (err) { - console.error('Failed to load run summary:', err); - timeline.innerHTML = '

Failed to load summary

'; - } - } - - renderRunSummary() { - if (!this.runSummaryData) return; - - const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData; - - // Update session info - const duration = lastUpdatedAt - startedAt; - document.getElementById('runSummarySessionInfo').textContent = - `${sessionName || 'Session'} - ${this.formatDuration(duration)} total`; - - // Filter and render events - const filteredEvents = this.filterRunSummaryEvents(events); - this.renderRunSummaryTimeline(filteredEvents); - } - - filterRunSummaryEvents(events) { - if (this.runSummaryFilter === 'all') return events; - - return events.filter(event => { - switch (this.runSummaryFilter) { - case 'errors': return event.severity === 'error'; - case 'warnings': return event.severity === 'warning' || event.severity === 'error'; - case 'respawn': return event.type.startsWith('respawn_') || event.type === 'state_stuck'; - case 'idle': return event.type === 'idle_detected' || event.type === 'working_detected'; - default: return true; - } - }); - } - - filterRunSummary(filter) { - this.runSummaryFilter = filter; - - // Update active state on buttons - document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.filter === filter); - }); - - this.renderRunSummary(); - } - - renderRunSummaryTimeline(events) { - const timeline = document.getElementById('runSummaryTimeline'); - - if (!events || events.length === 0) { - timeline.innerHTML = '

No events recorded yet

'; - return; - } - - // Reverse to show most recent first - const reversedEvents = [...events].reverse(); - - const html = reversedEvents.map(event => { - const time = new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false }); - const severityClass = `event-${event.severity}`; - const icon = this.getEventIcon(event.type, event.severity); - - return ` -
-
${icon}
-
-
- ${escapeHtml(event.title)} - ${time} -
- ${event.details ? `
${escapeHtml(event.details)}
` : ''} -
-
- `; - }).join(''); - - timeline.innerHTML = html; - } - - getEventIcon(type, severity) { - if (severity === 'error') return '❌'; // Red X - if (severity === 'warning') return '⚠'; // Warning triangle - if (severity === 'success') return '✔'; // Checkmark - - switch (type) { - case 'session_started': return '🚀'; // Rocket - case 'session_stopped': return '🛑'; // Stop sign - case 'respawn_cycle_started': return '🔄'; // Cycle - case 'respawn_cycle_completed': return '✅'; // Green check - case 'respawn_state_change': return '➡'; // Arrow - case 'token_milestone': return '💰'; // Money bag - case 'idle_detected': return '💤'; // Zzz - case 'working_detected': return '💻'; // Laptop - case 'ai_check_result': return '🤖'; // Robot - case 'hook_event': return '🔔'; // Bell - default: return '•'; // Bullet - } - } - - - formatDuration(ms) { - if (!ms || ms < 0) return '0s'; - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) return `${hours}h ${minutes % 60}m`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s`; - return `${seconds}s`; - } - - saveSessionOptions() { - // Session options are applied immediately via individual controls - // This just closes the modal - this.closeSessionOptions(); - } - - // ═══════════════════════════════════════════════════════════════ - // Session Options Modal Tabs - // ═══════════════════════════════════════════════════════════════ - - switchOptionsTab(tabName) { - // Toggle active class on tab buttons - document.querySelectorAll('#sessionOptionsModal .modal-tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.tab === tabName); - }); - - // Toggle hidden class on tab content - document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn'); - document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context'); - document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph'); - document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary'); - - // Load run summary data when switching to summary tab - if (tabName === 'summary' && this.editingSessionId) { - this.loadRunSummary(this.editingSessionId); - } - } - - getRalphConfig() { - return { - enabled: document.getElementById('modalRalphEnabled').checked, - completionPhrase: document.getElementById('modalRalphPhrase').value.trim(), - maxIterations: parseInt(document.getElementById('modalRalphMaxIterations').value) || 0, - maxTodos: parseInt(document.getElementById('modalRalphMaxTodos').value) || 50, - todoExpirationMinutes: parseInt(document.getElementById('modalRalphTodoExpiration').value) || 60 - }; - } - - populateRalphForm(config) { - document.getElementById('modalRalphEnabled').checked = config?.enabled ?? false; - document.getElementById('modalRalphPhrase').value = config?.completionPhrase || ''; - document.getElementById('modalRalphMaxIterations').value = config?.maxIterations || 0; - document.getElementById('modalRalphMaxTodos').value = config?.maxTodos || 50; - document.getElementById('modalRalphTodoExpiration').value = config?.todoExpirationMinutes || 60; - } - - async saveRalphConfig() { - if (!this.editingSessionId) { - this.showToast('No session selected', 'warning'); - return; - } - - const config = this.getRalphConfig(); - - // If user is enabling Ralph, clear from closed set - if (config.enabled) { - this.ralphClosedSessions.delete(this.editingSessionId); - } - - try { - const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - - this.showToast('Ralph config saved', 'success'); - } catch (err) { - this.showToast('Failed to save Ralph config: ' + err.message, 'error'); - } - } - - // Inline rename on right-click - startInlineRename(sessionId) { - const session = this.sessions.get(sessionId); - if (!session) return; - - const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`); - if (!tabName) return; - - const currentName = this.getSessionName(session); - const input = document.createElement('input'); - input.type = 'text'; - input.value = session.name || ''; - input.placeholder = currentName; - input.className = 'tab-rename-input'; - input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;'; - - const originalContent = tabName.textContent; - tabName.textContent = ''; - tabName.appendChild(input); - input.focus(); - input.select(); - - const finishRename = async () => { - const newName = input.value.trim(); - tabName.textContent = newName || originalContent; - - if (newName && newName !== session.name) { - try { - await fetch(`/api/sessions/${sessionId}/name`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newName }) - }); - } catch (err) { - tabName.textContent = originalContent; - this.showToast('Failed to rename', 'error'); - } - } - }; - - input.addEventListener('blur', finishRename); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - input.blur(); - } else if (e.key === 'Escape') { - input.value = ''; - input.blur(); - } - }); - } - - // ═══════════════════════════════════════════════════════════════ - // Web Push - // ═══════════════════════════════════════════════════════════════ - - registerServiceWorker() { - if (!('serviceWorker' in navigator)) return; - navigator.serviceWorker.register('/sw.js').then((reg) => { - this._swRegistration = reg; - // Listen for messages from service worker (notification clicks) - navigator.serviceWorker.addEventListener('message', (event) => { - if (event.data?.type === 'notification-click') { - const { sessionId } = event.data; - if (sessionId && this.sessions.has(sessionId)) { - this.selectSession(sessionId); - } - window.focus(); - } - }); - // Check if already subscribed - reg.pushManager.getSubscription().then((sub) => { - if (sub) { - this._pushSubscription = sub; - this._updatePushUI(true); - } - }); - }).catch(() => { - // Service worker registration failed (likely not HTTPS) - }); - } - - async subscribeToPush() { - if (!this._swRegistration) { - this.showToast('Service worker not available. HTTPS or localhost required.', 'error'); - return; - } - try { - // Get VAPID public key from server - const keyData = await this._apiJson('/api/push/vapid-key'); - if (!keyData?.success) throw new Error('Failed to get VAPID key'); - - const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey); - const subscription = await this._swRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey, - }); - - // Send subscription to server - const subJson = subscription.toJSON(); - const data = await this._apiJson('/api/push/subscribe', { - method: 'POST', - body: { - endpoint: subJson.endpoint, - keys: subJson.keys, - userAgent: navigator.userAgent, - pushPreferences: this._buildPushPreferences(), - }, - }); - if (!data?.success) throw new Error('Failed to register subscription'); - - this._pushSubscription = subscription; - this._pushSubscriptionId = data.data.id; - localStorage.setItem('codeman-push-subscription-id', data.data.id); - this._updatePushUI(true); - this.showToast('Push notifications enabled', 'success'); - } catch (err) { - this.showToast('Push subscription failed: ' + (err.message || err), 'error'); - } - } - - async unsubscribeFromPush() { - try { - if (this._pushSubscription) { - await this._pushSubscription.unsubscribe(); - } - const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); - if (subId) { - await fetch(`/api/push/subscribe/${subId}`, { method: 'DELETE' }).catch(() => {}); - } - this._pushSubscription = null; - this._pushSubscriptionId = null; - localStorage.removeItem('codeman-push-subscription-id'); - this._updatePushUI(false); - this.showToast('Push notifications disabled', 'success'); - } catch (err) { - this.showToast('Failed to unsubscribe: ' + (err.message || err), 'error'); - } - } - - async togglePushSubscription() { - if (this._pushSubscription) { - await this.unsubscribeFromPush(); - } else { - await this.subscribeToPush(); - } - } - - /** Sync push preferences to server */ - async _syncPushPreferences() { - const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); - if (!subId) return; - try { - await fetch(`/api/push/subscribe/${subId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pushPreferences: this._buildPushPreferences() }), - }); - } catch { - // Silently fail — prefs saved locally, will sync on next subscribe - } - } - - /** Build push preferences object from current event type checkboxes */ - _buildPushPreferences() { - const prefs = {}; - const eventMap = { - 'hook:permission_prompt': 'eventPermissionPush', - 'hook:elicitation_dialog': 'eventQuestionPush', - 'hook:idle_prompt': 'eventIdlePush', - 'hook:stop': 'eventStopPush', - 'respawn:blocked': 'eventRespawnPush', - 'session:ralphCompletionDetected': 'eventRalphPush', - }; - for (const [event, checkboxId] of Object.entries(eventMap)) { - const el = document.getElementById(checkboxId); - prefs[event] = el ? el.checked : true; - } - // session:error always receives push (no per-event toggle, always critical) - prefs['session:error'] = true; - return prefs; - } - - _updatePushUI(subscribed) { - const btn = document.getElementById('pushSubscribeBtn'); - const status = document.getElementById('pushSubscriptionStatus'); - if (btn) btn.textContent = subscribed ? 'Unsubscribe' : 'Subscribe'; - if (status) { - status.textContent = subscribed ? 'active' : 'off'; - status.classList.remove('granted', 'denied'); - if (subscribed) status.classList.add('granted'); - } - } - - // ═══════════════════════════════════════════════════════════════ - // App Settings Modal - // ═══════════════════════════════════════════════════════════════ - - openAppSettings() { - // Load current settings - const settings = this.loadAppSettingsFromStorage(); - document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || ''; - document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || ''; - // Use device-aware defaults for display settings (mobile has different defaults) - const defaults = this.getDefaultSettings(); - document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? defaults.ralphTrackerEnabled ?? false; - // Header visibility settings - document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false; - document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true; - document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true; - document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false; - document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true; - document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true; - document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; - document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; - document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; - document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; - document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; - document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; - document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; - this.loadTunnelStatus(); - document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); - document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; - // Claude CLI settings - const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); - const allowedToolsRow = document.getElementById('allowedToolsRow'); - claudeModeSelect.value = settings.claudeMode || 'dangerously-skip-permissions'; - document.getElementById('appSettingsAllowedTools').value = settings.allowedTools || ''; - allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; - // Toggle allowed tools row visibility based on mode selection - claudeModeSelect.onchange = () => { - allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; - }; - // Claude Permissions settings - document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; - // CPU Priority settings - const niceSettings = settings.nice || {}; - document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; - document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10; - // Model configuration (loaded from server) - this.loadModelConfigForSettings(); - // Notification settings - const notifPrefs = this.notificationManager?.preferences || {}; - document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true; - document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false; - document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false; - document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000); - document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical; - document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning; - document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo; - // Push notification settings - document.getElementById('appSettingsPushEnabled').checked = !!this._pushSubscription; - this._updatePushUI(!!this._pushSubscription); - // Per-event-type preferences - const eventTypes = notifPrefs.eventTypes || {}; - // Permission prompts - const permPref = eventTypes.permission_prompt || {}; - document.getElementById('eventPermissionEnabled').checked = permPref.enabled ?? true; - document.getElementById('eventPermissionBrowser').checked = permPref.browser ?? true; - document.getElementById('eventPermissionPush').checked = permPref.push ?? false; - document.getElementById('eventPermissionAudio').checked = permPref.audio ?? true; - // Questions (elicitation_dialog) - const questionPref = eventTypes.elicitation_dialog || {}; - document.getElementById('eventQuestionEnabled').checked = questionPref.enabled ?? true; - document.getElementById('eventQuestionBrowser').checked = questionPref.browser ?? true; - document.getElementById('eventQuestionPush').checked = questionPref.push ?? false; - document.getElementById('eventQuestionAudio').checked = questionPref.audio ?? true; - // Session idle (idle_prompt) - const idlePref = eventTypes.idle_prompt || {}; - document.getElementById('eventIdleEnabled').checked = idlePref.enabled ?? true; - document.getElementById('eventIdleBrowser').checked = idlePref.browser ?? true; - document.getElementById('eventIdlePush').checked = idlePref.push ?? false; - document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false; - // Response complete (stop) - const stopPref = eventTypes.stop || {}; - document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true; - document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false; - document.getElementById('eventStopPush').checked = stopPref.push ?? false; - document.getElementById('eventStopAudio').checked = stopPref.audio ?? false; - // Respawn cycles - const respawnPref = eventTypes.respawn_cycle || {}; - document.getElementById('eventRespawnEnabled').checked = respawnPref.enabled ?? true; - document.getElementById('eventRespawnBrowser').checked = respawnPref.browser ?? false; - document.getElementById('eventRespawnPush').checked = respawnPref.push ?? false; - document.getElementById('eventRespawnAudio').checked = respawnPref.audio ?? false; - // Task complete (ralph_complete) - const ralphPref = eventTypes.ralph_complete || {}; - document.getElementById('eventRalphEnabled').checked = ralphPref.enabled ?? true; - document.getElementById('eventRalphBrowser').checked = ralphPref.browser ?? true; - document.getElementById('eventRalphPush').checked = ralphPref.push ?? false; - document.getElementById('eventRalphAudio').checked = ralphPref.audio ?? true; - // Subagent activity (subagent_spawn and subagent_complete) - const subagentPref = eventTypes.subagent_spawn || {}; - document.getElementById('eventSubagentEnabled').checked = subagentPref.enabled ?? false; - document.getElementById('eventSubagentBrowser').checked = subagentPref.browser ?? false; - document.getElementById('eventSubagentPush').checked = subagentPref.push ?? false; - document.getElementById('eventSubagentAudio').checked = subagentPref.audio ?? false; - // Update permission status display (compact format for new grid layout) - const permStatus = document.getElementById('notifPermissionStatus'); - if (permStatus && typeof Notification !== 'undefined') { - const perm = Notification.permission; - permStatus.textContent = perm === 'granted' ? '\u2713' : perm === 'denied' ? '\u2717' : '?'; - permStatus.classList.remove('granted', 'denied'); - if (perm === 'granted') permStatus.classList.add('granted'); - else if (perm === 'denied') permStatus.classList.add('denied'); - } - // Voice settings (loaded from localStorage only) - const voiceCfg = VoiceInput._getDeepgramConfig(); - document.getElementById('voiceDeepgramKey').value = voiceCfg.apiKey || ''; - document.getElementById('voiceLanguage').value = voiceCfg.language || 'en-US'; - document.getElementById('voiceKeyterms').value = voiceCfg.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'; - document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct'; - // Reset key visibility to hidden - const keyInput = document.getElementById('voiceDeepgramKey'); - keyInput.type = 'password'; - document.getElementById('voiceKeyToggleBtn').textContent = 'Show'; - // Update provider status - const providerName = VoiceInput.getActiveProviderName(); - const providerEl = document.getElementById('voiceProviderStatus'); - providerEl.textContent = providerName; - providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : ''); - - // Reset to first tab and wire up tab switching - this.switchSettingsTab('settings-display'); - const modal = document.getElementById('appSettingsModal'); - modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { - btn.onclick = () => this.switchSettingsTab(btn.dataset.tab); - }); - modal.classList.add('active'); - - // Activate focus trap - this.activeFocusTrap = new FocusTrap(modal); - this.activeFocusTrap.activate(); - } - - switchSettingsTab(tabName) { - const modal = document.getElementById('appSettingsModal'); - // Toggle active class on tab buttons - modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.tab === tabName); - }); - // Toggle hidden class on tab content - modal.querySelectorAll('.modal-tab-content').forEach(content => { - content.classList.toggle('hidden', content.id !== tabName); - }); - } - - closeAppSettings() { - document.getElementById('appSettingsModal').classList.remove('active'); - - // Deactivate focus trap and restore focus - if (this.activeFocusTrap) { - this.activeFocusTrap.deactivate(); - this.activeFocusTrap = null; - } - } - - async loadTunnelStatus() { - try { - const res = await fetch('/api/tunnel/status'); - const status = await res.json(); - const active = status.running && status.url; - this._tunnelUrl = active ? status.url : null; - this._updateTunnelUrlDisplay(this._tunnelUrl); - this._updateWelcomeTunnelBtn(!!active, this._tunnelUrl); - this._updateTunnelIndicator(!!active); - } catch { - this._tunnelUrl = null; - this._updateTunnelUrlDisplay(null); - this._updateWelcomeTunnelBtn(false); - this._updateTunnelIndicator(false); - } - } - - _updateTunnelUrlDisplay(url) { - const row = document.getElementById('tunnelUrlRow'); - const display = document.getElementById('tunnelUrlDisplay'); - if (!row || !display) return; - if (url) { - row.style.display = ''; - display.textContent = url; - display.onclick = () => { - navigator.clipboard.writeText(url).then(() => { - this.showToast('Tunnel URL copied', 'success'); - }); - }; - } else { - row.style.display = 'none'; - display.textContent = ''; - display.onclick = null; - } - // Upload URL row - const uploadRow = document.getElementById('tunnelUploadUrlRow'); - const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay'); - if (!uploadRow || !uploadDisplay) return; - if (url) { - const uploadUrl = url + '/upload.html'; - uploadRow.style.display = ''; - uploadDisplay.textContent = uploadUrl; - uploadDisplay.onclick = () => { - navigator.clipboard.writeText(uploadUrl).then(() => { - this.showToast('Upload URL copied', 'success'); - }); - }; - } else { - uploadRow.style.display = 'none'; - uploadDisplay.textContent = ''; - uploadDisplay.onclick = null; - } - } - - showTunnelQR() { - // Close existing popup if open - this.closeTunnelQR(); - - const overlay = document.createElement('div'); - overlay.id = 'tunnelQrOverlay'; - overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:pointer'; - overlay.onclick = (e) => { if (e.target === overlay) this.closeTunnelQR(); }; - - const card = document.createElement('div'); - card.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center;max-width:340px;width:90vw;box-shadow:var(--shadow-lg);cursor:default'; - - card.innerHTML = ` -
Scan to connect
-
-
Loading...
-
-
- - `; - - overlay.appendChild(card); - document.body.appendChild(overlay); - - // Fetch QR SVG from server - fetch('/api/tunnel/qr') - .then(res => { - if (!res.ok) throw new Error('Tunnel not running'); - return res.json(); - }) - .then(data => { - const container = document.getElementById('tunnelQrContainer'); - if (container && data.svg) container.innerHTML = data.svg; - // Show auth badge, countdown, and regenerate button when auth is enabled - if (data.authEnabled) { - const badge = document.createElement('div'); - badge.id = 'tunnelQrBadge'; - badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)'; - badge.textContent = 'Single-use auth \u00b7 expires in 60s'; - const regenBtn = document.createElement('button'); - regenBtn.textContent = 'Regenerate QR'; - regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px'; - regenBtn.onclick = () => { - fetch('/api/tunnel/qr/regenerate', { method: 'POST' }) - .then(() => this.showToast('QR code regenerated', 'success')) - .catch(() => this.showToast('Failed to regenerate QR', 'error')); - }; - const card = container.parentElement; - if (card) { - card.appendChild(badge); - card.appendChild(regenBtn); - } - this._resetQrCountdown(); - } - }) - .catch(() => { - const container = document.getElementById('tunnelQrContainer'); - if (container) container.innerHTML = '
Tunnel not active
'; - }); - - // Fetch URL for display - fetch('/api/tunnel/status') - .then(r => r.json()) - .then(status => { - const urlEl = document.getElementById('tunnelQrUrl'); - if (urlEl && status.url) { - urlEl.textContent = status.url; - urlEl.onclick = () => { - navigator.clipboard.writeText(status.url).then(() => { - this.showToast('Tunnel URL copied', 'success'); - }); - }; - } - }) - .catch(() => {}); - - // Close on Escape - this._tunnelQrEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelQR(); }; - document.addEventListener('keydown', this._tunnelQrEscHandler); - } - - closeTunnelQR() { - const overlay = document.getElementById('tunnelQrOverlay'); - if (overlay) overlay.remove(); - if (this._tunnelQrEscHandler) { - document.removeEventListener('keydown', this._tunnelQrEscHandler); - this._tunnelQrEscHandler = null; - } - this._clearQrCountdown(); - } - - /** Fallback: fetch QR SVG from API when SSE payload lacks it */ - _refreshTunnelQrFromApi() { - fetch('/api/tunnel/qr') - .then(res => res.ok ? res.json() : null) - .then(data => { - if (!data?.svg) return; - const container = document.getElementById('tunnelQrContainer'); - if (container) container.innerHTML = data.svg; - const welcomeInner = document.getElementById('welcomeQrInner'); - if (welcomeInner) welcomeInner.innerHTML = data.svg; - }) - .catch(() => {}); - } - - /** Start or reset the 60s countdown on the QR badge */ - _resetQrCountdown() { - this._clearQrCountdown(); - this._qrCountdownSec = 60; - this._updateQrCountdownText(); - this._qrCountdownTimer = setInterval(() => { - this._qrCountdownSec--; - if (this._qrCountdownSec <= 0) { - this._clearQrCountdown(); - return; - } - this._updateQrCountdownText(); - }, 1000); - } - - _updateQrCountdownText() { - const badge = document.getElementById('tunnelQrBadge'); - if (badge) { - badge.textContent = `Single-use auth \u00b7 expires in ${this._qrCountdownSec}s`; - } - } - - _clearQrCountdown() { - if (this._qrCountdownTimer) { - clearInterval(this._qrCountdownTimer); - this._qrCountdownTimer = null; - } - } - - async toggleTunnelFromWelcome() { - const btn = document.getElementById('welcomeTunnelBtn'); - if (!btn) return; - const isActive = btn.classList.contains('active'); - btn.disabled = true; - try { - const newEnabled = !isActive; - await fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: newEnabled }), - }); - if (newEnabled) { - this._showTunnelConnecting(); - // Poll tunnel status as fallback in case SSE event is missed - this._pollTunnelStatus(); - } else { - this._dismissTunnelConnecting(); - this.showToast('Tunnel stopped', 'info'); - this._updateWelcomeTunnelBtn(false); - btn.disabled = false; - } - } catch (err) { - this._dismissTunnelConnecting(); - this.showToast('Failed to toggle tunnel', 'error'); - btn.disabled = false; - } - } - - _showTunnelConnecting() { - // Remove any existing connecting toast first (without resetting button state) - const oldToast = document.getElementById('tunnelConnectingToast'); - if (oldToast) { - oldToast.remove(); - } - const btn = document.getElementById('welcomeTunnelBtn'); - if (btn) { - btn.classList.add('connecting'); - btn.innerHTML = ` - - Connecting...`; - } - // Persistent toast with spinner - const toast = document.createElement('div'); - toast.className = 'toast toast-info show'; - toast.id = 'tunnelConnectingToast'; - toast.innerHTML = ' Cloudflare Tunnel connecting...'; - toast.style.pointerEvents = 'auto'; - if (!this._toastContainer) { - this._toastContainer = document.querySelector('.toast-container'); - if (!this._toastContainer) { - this._toastContainer = document.createElement('div'); - this._toastContainer.className = 'toast-container'; - document.body.appendChild(this._toastContainer); - } - } - this._toastContainer.appendChild(toast); - } - - _dismissTunnelConnecting() { - clearTimeout(this._tunnelPollTimer); - this._tunnelPollTimer = null; - const toast = document.getElementById('tunnelConnectingToast'); - if (toast) { - toast.classList.remove('show'); - setTimeout(() => toast.remove(), 200); - } - const btn = document.getElementById('welcomeTunnelBtn'); - if (btn) btn.classList.remove('connecting'); - } - - _pollTunnelStatus(attempt = 0) { - if (attempt > 15) return; // give up after ~30s - this._tunnelPollTimer = setTimeout(async () => { - try { - const res = await fetch('/api/tunnel/status'); - const status = await res.json(); - if (status.running && status.url) { - // Tunnel is up — update UI - this._dismissTunnelConnecting(); - this._updateTunnelUrlDisplay(status.url); - const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible'); - if (welcomeVisible) { - this._updateWelcomeTunnelBtn(true, status.url, true); - this.showToast('Tunnel active', 'success'); - } else { - this._updateWelcomeTunnelBtn(true, status.url); - this.showToast(`Tunnel active: ${status.url}`, 'success'); - this.showTunnelQR(); - } - return; - } - } catch { /* ignore */ } - this._pollTunnelStatus(attempt + 1); - }, 2000); - } - - _updateWelcomeTunnelBtn(active, url, firstAppear = false) { - const btn = document.getElementById('welcomeTunnelBtn'); - if (btn) { - btn.disabled = false; - if (active) { - btn.classList.remove('connecting'); - btn.classList.add('active'); - btn.innerHTML = ` - - Tunnel Active`; - } else { - btn.classList.remove('active', 'connecting'); - btn.innerHTML = ` - - Cloudflare Tunnel`; - } - } - // Update welcome QR code - const qrWrap = document.getElementById('welcomeQr'); - const qrInner = document.getElementById('welcomeQrInner'); - const qrUrl = document.getElementById('welcomeQrUrl'); - if (!qrWrap || !qrInner) return; - if (active) { - qrWrap.classList.add('visible'); - // First appear: start expanded, auto-shrink after 8s - if (firstAppear) { - qrWrap.classList.add('expanded'); - clearTimeout(this._welcomeQrShrinkTimer); - this._welcomeQrShrinkTimer = setTimeout(() => { - qrWrap.classList.remove('expanded'); - }, 8000); - } - if (url) { - qrUrl.textContent = url; - qrUrl.title = 'Click QR to enlarge'; - } - fetch('/api/tunnel/qr') - .then(r => { if (!r.ok) throw new Error(); return r.json(); }) - .then(data => { if (data.svg) qrInner.innerHTML = data.svg; }) - .catch(() => { qrInner.innerHTML = '
QR unavailable
'; }); - } else { - clearTimeout(this._welcomeQrShrinkTimer); - qrWrap.classList.remove('visible', 'expanded'); - qrInner.innerHTML = ''; - if (qrUrl) qrUrl.textContent = ''; - } - } - - toggleWelcomeQrSize() { - const qrWrap = document.getElementById('welcomeQr'); - if (qrWrap) { - clearTimeout(this._welcomeQrShrinkTimer); - qrWrap.classList.toggle('expanded'); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Tunnel Header Indicator & Panel (desktop only) - // ═══════════════════════════════════════════════════════════════ - - _updateTunnelIndicator(active) { - if (MobileDetection.getDeviceType() === 'mobile') return; - const indicator = document.getElementById('tunnelIndicator'); - if (!indicator) return; - indicator.style.display = active ? 'flex' : 'none'; - indicator.classList.remove('connecting'); - } - - toggleTunnelPanel() { - const existing = document.getElementById('tunnelPanel'); - if (existing) { - this.closeTunnelPanel(); - return; - } - this._openTunnelPanel(); - } - - async _openTunnelPanel() { - const panel = document.createElement('div'); - panel.className = 'tunnel-panel'; - panel.id = 'tunnelPanel'; - panel.innerHTML = ` -
-

- - Cloudflare Tunnel - Loading... -

-
-
-
Loading...
-
- `; - document.body.appendChild(panel); - - // Close on outside click - this._tunnelPanelClickHandler = (e) => { - if (!panel.contains(e.target) && e.target.id !== 'tunnelIndicator' && !e.target.closest('.tunnel-indicator')) { - this.closeTunnelPanel(); - } - }; - setTimeout(() => document.addEventListener('click', this._tunnelPanelClickHandler), 0); - - // Close on Escape - this._tunnelPanelEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelPanel(); }; - document.addEventListener('keydown', this._tunnelPanelEscHandler); - - // Fetch tunnel info - try { - const res = await fetch('/api/tunnel/info'); - const info = await res.json(); - this._renderTunnelPanel(info); - } catch { - const body = document.getElementById('tunnelPanelBody'); - if (body) body.innerHTML = '
Failed to load tunnel info
'; - } - } - - _renderTunnelPanel(info) { - const statusEl = document.getElementById('tunnelPanelStatus'); - const body = document.getElementById('tunnelPanelBody'); - if (!statusEl || !body) return; - - statusEl.textContent = info.running ? 'Connected' : 'Offline'; - statusEl.className = 'tunnel-panel-status' + (info.running ? '' : ' offline'); - - let html = ''; - - // URL section - if (info.url) { - html += ` -
-
URL
-
${escapeHtml(info.url)}
-
`; - } - - // Clients section - html += ` -
-
Connections
-
- Remote Clients - ${info.sseClients} -
`; - - if (info.authEnabled) { - html += ` -
- Auth Sessions - ${info.authSessions.length} -
`; - } - html += '
'; - - // Auth sessions detail - if (info.authEnabled && info.authSessions.length > 0) { - html += '
Authenticated Devices
'; - for (const s of info.authSessions) { - const ua = s.ua || 'Unknown'; - const browser = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser'; - const ago = this._formatTimeAgo(s.createdAt); - html += ` -
- - ${escapeHtml(browser)} · ${escapeHtml(s.ip)} · ${ago} - ${s.method} -
`; - } - html += '
'; - } - - // Actions - html += '
'; - if (info.running) { - html += ` - - `; - } else { - html += ``; - } - html += '
'; - - // Revoke all sessions button - if (info.authEnabled && info.authSessions.length > 0) { - html += ` -
- -
`; - } - - body.innerHTML = html; - - // Bind URL copy handler - const urlEl = document.getElementById('tunnelPanelUrl'); - if (urlEl) { - urlEl.onclick = () => { - navigator.clipboard.writeText(info.url).then(() => this.showToast('Tunnel URL copied', 'success')); - }; - } - } - - _formatTimeAgo(timestamp) { - const diff = Date.now() - timestamp; - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - return `${Math.floor(hrs / 24)}d ago`; - } - - async _tunnelPanelToggle(enable) { - try { - await fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tunnelEnabled: enable }), - }); - if (enable) { - this._updateTunnelIndicator(false); - const indicator = document.getElementById('tunnelIndicator'); - if (indicator) { - indicator.style.display = 'flex'; - indicator.classList.add('connecting'); - } - this.showToast('Tunnel starting...', 'info'); - this._showTunnelConnecting(); - this._pollTunnelStatus(); - } else { - this.showToast('Tunnel stopped', 'info'); - } - this.closeTunnelPanel(); - } catch { - this.showToast('Failed to toggle tunnel', 'error'); - } - } - - async _tunnelPanelRevokeAll() { - try { - await fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); - this.showToast('All sessions revoked', 'success'); - // Refresh panel - const res = await fetch('/api/tunnel/info'); - const info = await res.json(); - this._renderTunnelPanel(info); - } catch { - this.showToast('Failed to revoke sessions', 'error'); - } - } - - closeTunnelPanel() { - const panel = document.getElementById('tunnelPanel'); - if (panel) panel.remove(); - if (this._tunnelPanelClickHandler) { - document.removeEventListener('click', this._tunnelPanelClickHandler); - this._tunnelPanelClickHandler = null; - } - if (this._tunnelPanelEscHandler) { - document.removeEventListener('keydown', this._tunnelPanelEscHandler); - this._tunnelPanelEscHandler = null; - } - } - - toggleDeepgramKeyVisibility() { - const input = document.getElementById('voiceDeepgramKey'); - const btn = document.getElementById('voiceKeyToggleBtn'); - if (input.type === 'password') { - input.type = 'text'; - btn.textContent = 'Hide'; - } else { - input.type = 'password'; - btn.textContent = 'Show'; - } - } - - // ═══════════════════════════════════════════════════════════════ - // Session Lifecycle Log - // ═══════════════════════════════════════════════════════════════ - - openLifecycleLog() { - const win = document.getElementById('lifecycleWindow'); - win.style.display = 'block'; - // Reset transform so it appears centered initially - if (!win._dragInitialized) { - win.style.left = '50%'; - win.style.transform = 'translateX(-50%)'; - this._initLifecycleDrag(win); - win._dragInitialized = true; - } - this.loadLifecycleLog(); - } - - closeLifecycleLog() { - document.getElementById('lifecycleWindow').style.display = 'none'; - } - - _initLifecycleDrag(win) { - const header = document.getElementById('lifecycleWindowHeader'); - let isDragging = false; - let startX, startY, startLeft, startTop; - - header.addEventListener('mousedown', (e) => { - if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; - isDragging = true; - // Clear transform so left/top work in absolute pixels - const rect = win.getBoundingClientRect(); - win.style.transform = 'none'; - win.style.left = rect.left + 'px'; - win.style.top = rect.top + 'px'; - startX = e.clientX; - startY = e.clientY; - startLeft = rect.left; - startTop = rect.top; - e.preventDefault(); - }); - - document.addEventListener('mousemove', (e) => { - if (!isDragging) return; - win.style.left = (startLeft + e.clientX - startX) + 'px'; - win.style.top = (startTop + e.clientY - startY) + 'px'; - }); - - document.addEventListener('mouseup', () => { - isDragging = false; - }); - } - - async loadLifecycleLog() { - const eventFilter = document.getElementById('lifecycleFilterEvent').value; - const sessionFilter = document.getElementById('lifecycleFilterSession').value.trim(); - const params = new URLSearchParams(); - if (eventFilter) params.set('event', eventFilter); - if (sessionFilter) params.set('sessionId', sessionFilter); - params.set('limit', '300'); - - try { - const res = await fetch(`/api/session-lifecycle?${params}`); - const data = await res.json(); - const tbody = document.getElementById('lifecycleTableBody'); - const empty = document.getElementById('lifecycleEmpty'); - - if (!data.entries || data.entries.length === 0) { - tbody.innerHTML = ''; - empty.style.display = ''; - return; - } - empty.style.display = 'none'; - - const eventColors = { - created: '#4ade80', started: '#4ade80', recovered: '#4ade80', - exit: '#fbbf24', mux_died: '#f87171', deleted: '#f87171', stale_cleaned: '#f87171', - server_started: '#666', server_stopped: '#666', - }; - - tbody.innerHTML = data.entries.map(e => { - const time = new Date(e.ts).toLocaleString(); - const color = eventColors[e.event] || '#888'; - const name = e.name || (e.sessionId === '*' ? '—' : this.getShortId(e.sessionId)); - const extra = []; - if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`); - if (e.mode) extra.push(e.mode); - return ` - ${time} - ${e.event} - ${name} - ${e.reason || ''} - ${extra.join(', ')} - `; - }).join(''); - } catch (err) { - console.error('Failed to load lifecycle log:', err); - } - } - - async saveAppSettings() { - const settings = { - defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(), - defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(), - ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked, - // Header visibility settings - showFontControls: document.getElementById('appSettingsShowFontControls').checked, - showSystemStats: document.getElementById('appSettingsShowSystemStats').checked, - showTokenCount: document.getElementById('appSettingsShowTokenCount').checked, - showCost: document.getElementById('appSettingsShowCost').checked, - showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked, - showMonitor: document.getElementById('appSettingsShowMonitor').checked, - showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked, - showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked, - showSubagents: document.getElementById('appSettingsShowSubagents').checked, - subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, - subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, - imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, - tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, - localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, - tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, - // Claude CLI settings - claudeMode: document.getElementById('appSettingsClaudeMode').value, - allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), - // Claude Permissions settings - agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, - // CPU Priority settings - nice: { - enabled: document.getElementById('appSettingsNiceEnabled').checked, - niceValue: parseInt(document.getElementById('appSettingsNiceValue').value) || 10, - }, - }; - - // Save to localStorage - this.saveAppSettingsToStorage(settings); - this._updateLocalEchoState(); - - // Save voice settings to localStorage + include in server payload for cross-device sync - const voiceSettings = { - apiKey: document.getElementById('voiceDeepgramKey').value.trim(), - language: document.getElementById('voiceLanguage').value, - keyterms: document.getElementById('voiceKeyterms').value.trim(), - insertMode: document.getElementById('voiceInsertMode').value, - }; - VoiceInput._saveDeepgramConfig(voiceSettings); - - // Save notification preferences separately - const notifPrefsToSave = { - enabled: document.getElementById('appSettingsNotifEnabled').checked, - browserNotifications: document.getElementById('appSettingsNotifBrowser').checked, - audioAlerts: document.getElementById('appSettingsNotifAudio').checked, - stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000, - muteCritical: !document.getElementById('appSettingsNotifCritical').checked, - muteWarning: !document.getElementById('appSettingsNotifWarning').checked, - muteInfo: !document.getElementById('appSettingsNotifInfo').checked, - // Per-event-type preferences - eventTypes: { - permission_prompt: { - enabled: document.getElementById('eventPermissionEnabled').checked, - browser: document.getElementById('eventPermissionBrowser').checked, - push: document.getElementById('eventPermissionPush').checked, - audio: document.getElementById('eventPermissionAudio').checked, - }, - elicitation_dialog: { - enabled: document.getElementById('eventQuestionEnabled').checked, - browser: document.getElementById('eventQuestionBrowser').checked, - push: document.getElementById('eventQuestionPush').checked, - audio: document.getElementById('eventQuestionAudio').checked, - }, - idle_prompt: { - enabled: document.getElementById('eventIdleEnabled').checked, - browser: document.getElementById('eventIdleBrowser').checked, - push: document.getElementById('eventIdlePush').checked, - audio: document.getElementById('eventIdleAudio').checked, - }, - stop: { - enabled: document.getElementById('eventStopEnabled').checked, - browser: document.getElementById('eventStopBrowser').checked, - push: document.getElementById('eventStopPush').checked, - audio: document.getElementById('eventStopAudio').checked, - }, - session_error: { - enabled: true, - browser: this.notificationManager?.preferences?.eventTypes?.session_error?.browser ?? true, - push: this.notificationManager?.preferences?.eventTypes?.session_error?.push ?? false, - audio: false, - }, - respawn_cycle: { - enabled: document.getElementById('eventRespawnEnabled').checked, - browser: document.getElementById('eventRespawnBrowser').checked, - push: document.getElementById('eventRespawnPush').checked, - audio: document.getElementById('eventRespawnAudio').checked, - }, - token_milestone: { - enabled: true, - browser: false, - push: false, - audio: false, - }, - ralph_complete: { - enabled: document.getElementById('eventRalphEnabled').checked, - browser: document.getElementById('eventRalphBrowser').checked, - push: document.getElementById('eventRalphPush').checked, - audio: document.getElementById('eventRalphAudio').checked, - }, - subagent_spawn: { - enabled: document.getElementById('eventSubagentEnabled').checked, - browser: document.getElementById('eventSubagentBrowser').checked, - push: document.getElementById('eventSubagentPush').checked, - audio: document.getElementById('eventSubagentAudio').checked, - }, - subagent_complete: { - enabled: document.getElementById('eventSubagentEnabled').checked, - browser: document.getElementById('eventSubagentBrowser').checked, - push: document.getElementById('eventSubagentPush').checked, - audio: document.getElementById('eventSubagentAudio').checked, - }, - }, - _version: 4, - }; - if (this.notificationManager) { - this.notificationManager.preferences = notifPrefsToSave; - this.notificationManager.savePreferences(); - } - - // Sync push preferences to server - this._syncPushPreferences(); - - // Apply header visibility immediately - this.applyHeaderVisibilitySettings(); - this.applyTabWrapSettings(); - this._updateTokensImmediate(); // Re-render token display (picks up showCost change) - this.applyMonitorVisibility(); - this.renderProjectInsightsPanel(); // Re-render to apply visibility setting - this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting - - // Save to server (includes notification prefs for cross-browser persistence) - // Strip device-specific keys — localEchoEnabled is per-platform (touch default differs) - const { localEchoEnabled: _leo, ...serverSettings } = settings; - try { - await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); - - // Save model configuration separately - await this.saveModelConfigFromSettings(); - - this.showToast('Settings saved', 'success'); - - // Show tunnel-specific feedback if toggled on - if (settings.tunnelEnabled) { - this.showToast('Tunnel starting — QR code will appear when ready...', 'info'); - } - } catch (err) { - // Server save failed but localStorage succeeded - this.showToast('Settings saved locally', 'warning'); - } - - this.closeAppSettings(); - } - - // Load model configuration from server for the settings modal - async loadModelConfigForSettings() { - try { - const res = await fetch('/api/execution/model-config'); - const data = await res.json(); - if (data.success && data.data) { - const config = data.data; - // Default model - const defaultModelEl = document.getElementById('appSettingsDefaultModel'); - if (defaultModelEl) { - defaultModelEl.value = config.defaultModel || 'opus'; - } - // Show recommendations - const showRecsEl = document.getElementById('appSettingsShowModelRecommendations'); - if (showRecsEl) { - showRecsEl.checked = config.showRecommendations ?? true; - } - // Agent type overrides - const overrides = config.agentTypeOverrides || {}; - const exploreEl = document.getElementById('appSettingsModelExplore'); - const implementEl = document.getElementById('appSettingsModelImplement'); - const testEl = document.getElementById('appSettingsModelTest'); - const reviewEl = document.getElementById('appSettingsModelReview'); - if (exploreEl) exploreEl.value = overrides.explore || ''; - if (implementEl) implementEl.value = overrides.implement || ''; - if (testEl) testEl.value = overrides.test || ''; - if (reviewEl) reviewEl.value = overrides.review || ''; - } - } catch (err) { - console.warn('Failed to load model config:', err); - } - } - - // Save model configuration from settings modal to server - async saveModelConfigFromSettings() { - const defaultModelEl = document.getElementById('appSettingsDefaultModel'); - const showRecsEl = document.getElementById('appSettingsShowModelRecommendations'); - const exploreEl = document.getElementById('appSettingsModelExplore'); - const implementEl = document.getElementById('appSettingsModelImplement'); - const testEl = document.getElementById('appSettingsModelTest'); - const reviewEl = document.getElementById('appSettingsModelReview'); - - const agentTypeOverrides = {}; - if (exploreEl?.value) agentTypeOverrides.explore = exploreEl.value; - if (implementEl?.value) agentTypeOverrides.implement = implementEl.value; - if (testEl?.value) agentTypeOverrides.test = testEl.value; - if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value; - - const config = { - defaultModel: defaultModelEl?.value || 'opus', - showRecommendations: showRecsEl?.checked ?? true, - agentTypeOverrides, - }; - - try { - await fetch('/api/execution/model-config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - } catch (err) { - console.warn('Failed to save model config:', err); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Visibility Settings & Device-Specific Defaults - // ═══════════════════════════════════════════════════════════════ - - // Get the global Ralph tracker enabled setting - isRalphTrackerEnabledByDefault() { - const settings = this.loadAppSettingsFromStorage(); - return settings.ralphTrackerEnabled ?? false; - } - - // Get the settings storage key based on device type (mobile vs desktop) - getSettingsStorageKey() { - const isMobile = MobileDetection.getDeviceType() === 'mobile'; - return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings'; - } - - // Get default settings based on device type - // Note: Notification prefs are handled separately by NotificationManager - getDefaultSettings() { - const isMobile = MobileDetection.getDeviceType() === 'mobile'; - if (isMobile) { - // Mobile defaults: minimal UI for small screens - return { - // Header visibility - hide everything on mobile - showFontControls: false, - showSystemStats: false, - showTokenCount: false, - showCost: false, - // Panel visibility - hide panels on mobile (not enough space) - showMonitor: false, - showProjectInsights: false, - showFileBrowser: false, - showSubagents: false, - // Feature toggles - keep tracking on even on mobile - subagentTrackingEnabled: true, - subagentActiveTabOnly: true, // Only show subagents for active tab - imageWatcherEnabled: false, - ralphTrackerEnabled: false, - tabTwoRows: false, - }; - } - // Desktop defaults - rely on ?? operators in apply functions - // This allows desktop to have different defaults without duplication - return {}; - } - - loadAppSettingsFromStorage() { - // Return cached settings if available (avoids synchronous localStorage + JSON.parse - // on every SSE event — critical for input responsiveness) - if (this._cachedAppSettings) return this._cachedAppSettings; - try { - const key = this.getSettingsStorageKey(); - const saved = localStorage.getItem(key); - if (saved) { - this._cachedAppSettings = JSON.parse(saved); - return this._cachedAppSettings; - } - } catch (err) { - console.error('Failed to load app settings:', err); - } - // Return device-specific defaults - this._cachedAppSettings = this.getDefaultSettings(); - return this._cachedAppSettings; - } - - saveAppSettingsToStorage(settings) { - // Invalidate cache on save - this._cachedAppSettings = settings; - try { - const key = this.getSettingsStorageKey(); - localStorage.setItem(key, JSON.stringify(settings)); - } catch (err) { - console.error('Failed to save app settings:', err); - } - } - - applyHeaderVisibilitySettings() { - const settings = this.loadAppSettingsFromStorage(); - const defaults = this.getDefaultSettings(); - const showFontControls = settings.showFontControls ?? defaults.showFontControls ?? false; - const showSystemStats = settings.showSystemStats ?? defaults.showSystemStats ?? true; - const showTokenCount = settings.showTokenCount ?? defaults.showTokenCount ?? true; - - const fontControlsEl = document.querySelector('.header-font-controls'); - const systemStatsEl = document.getElementById('headerSystemStats'); - const tokenCountEl = document.getElementById('headerTokens'); - - if (fontControlsEl) { - fontControlsEl.style.display = showFontControls ? '' : 'none'; - } - if (systemStatsEl) { - systemStatsEl.style.display = showSystemStats ? '' : 'none'; - } - if (tokenCountEl) { - tokenCountEl.style.display = showTokenCount ? '' : 'none'; - } - - // Hide lifecycle log button when setting is disabled - const showLifecycleLog = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true; - const lifecycleBtn = document.querySelector('.btn-lifecycle-log'); - if (lifecycleBtn) { - lifecycleBtn.style.display = showLifecycleLog ? '' : 'none'; - } - - // Hide notification bell when notifications are disabled - const notifEnabled = this.notificationManager?.preferences?.enabled ?? true; - const notifBtn = document.querySelector('.btn-notifications'); - if (notifBtn) { - notifBtn.style.display = notifEnabled ? '' : 'none'; - } - // Close the drawer if notifications got disabled while it's open - if (!notifEnabled) { - const drawer = document.getElementById('notifDrawer'); - if (drawer) drawer.classList.remove('open'); - } - } - - applyTabWrapSettings() { - const settings = this.loadAppSettingsFromStorage(); - const defaults = this.getDefaultSettings(); - const deviceType = MobileDetection.getDeviceType(); - // Two-row tabs disabled on mobile/tablet — not enough screen space - const twoRows = deviceType === 'desktop' - ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) - : false; - const prevTallTabs = this._tallTabsEnabled; - this._tallTabsEnabled = twoRows; - const tabsEl = document.getElementById('sessionTabs'); - if (tabsEl) { - tabsEl.classList.toggle('tabs-two-rows', twoRows); - tabsEl.classList.toggle('tabs-show-folder', twoRows); - } - // Re-render tabs if folder visibility changed (folder spans are generated in JS) - if (prevTallTabs !== undefined && prevTallTabs !== twoRows) { - this._fullRenderSessionTabs(); - } - } - - applyMonitorVisibility() { - const settings = this.loadAppSettingsFromStorage(); - const defaults = this.getDefaultSettings(); - const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true; - const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? false; - const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; - - const monitorPanel = document.getElementById('monitorPanel'); - if (monitorPanel) { - monitorPanel.style.display = showMonitor ? '' : 'none'; - if (showMonitor) { - monitorPanel.classList.add('open'); - } else { - monitorPanel.classList.remove('open'); - } - } - - const subagentsPanel = document.getElementById('subagentsPanel'); - if (subagentsPanel) { - if (showSubagents) { - subagentsPanel.classList.remove('hidden'); - } else { - subagentsPanel.classList.add('hidden'); - } - } - - // File browser panel visibility - const fileBrowserPanel = document.getElementById('fileBrowserPanel'); - if (fileBrowserPanel) { - if (showFileBrowser && this.activeSessionId) { - fileBrowserPanel.classList.add('visible'); - this.loadFileBrowser(this.activeSessionId); - // Attach drag listeners if not already attached - if (!this.fileBrowserDragListeners) { - const header = fileBrowserPanel.querySelector('.file-browser-header'); - if (header) { - // Convert right-positioned to left/top before drag so makeWindowDraggable works - const onFirstDrag = () => { - if (!fileBrowserPanel.style.left) { - const rect = fileBrowserPanel.getBoundingClientRect(); - fileBrowserPanel.style.left = `${rect.left}px`; - fileBrowserPanel.style.top = `${rect.top}px`; - fileBrowserPanel.style.right = 'auto'; - } - }; - header.addEventListener('mousedown', onFirstDrag); - header.addEventListener('touchstart', onFirstDrag, { passive: true }); - this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header); - this.fileBrowserDragListeners._onFirstDrag = onFirstDrag; - } - } - } else { - fileBrowserPanel.classList.remove('visible'); - } - } - } - - closeMonitor() { - // Hide the monitor panel - const monitorPanel = document.getElementById('monitorPanel'); - if (monitorPanel) { - monitorPanel.classList.remove('open'); - monitorPanel.style.display = 'none'; - } - // Save the setting - const settings = this.loadAppSettingsFromStorage(); - settings.showMonitor = false; - this.saveAppSettingsToStorage(settings); - } - - closeSubagentsPanel() { - // Hide the subagents panel - const subagentsPanel = document.getElementById('subagentsPanel'); - if (subagentsPanel) { - subagentsPanel.classList.remove('open'); - subagentsPanel.classList.add('hidden'); - } - this.subagentPanelVisible = false; - // Save the setting - const settings = this.loadAppSettingsFromStorage(); - settings.showSubagents = false; - this.saveAppSettingsToStorage(settings); - } - - async clearAllSubagents() { - const count = this.subagents.size; - if (count === 0) { - this.showToast('No subagents to clear', 'info'); - return; - } - - if (!confirm(`Clear all ${count} tracked subagent(s)? This removes them from the UI but does not affect running processes.`)) { - return; - } - - try { - const res = await fetch('/api/subagents', { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - // Clear local state - this.subagents.clear(); - this.subagentActivity.clear(); - this.subagentToolResults.clear(); - // Close any open subagent windows - this.cleanupAllFloatingWindows(); - // Update UI - this.renderSubagentPanel(); - this.renderMonitorSubagents(); - this.updateSubagentBadge(); - this.showToast(`Cleared ${data.data.cleared} subagent(s)`, 'success'); - } else { - this.showToast('Failed to clear subagents: ' + data.error, 'error'); - } - } catch (err) { - this.showToast('Failed to clear subagents', 'error'); - } - } - - toggleSubagentsPanel() { - const panel = document.getElementById('subagentsPanel'); - const toggleBtn = document.getElementById('subagentsToggleBtn'); - if (!panel) return; - - // If hidden, show it first - if (panel.classList.contains('hidden')) { - panel.classList.remove('hidden'); - // Save setting - const settings = this.loadAppSettingsFromStorage(); - settings.showSubagents = true; - this.saveAppSettingsToStorage(settings); - } - - // Toggle open/collapsed state - panel.classList.toggle('open'); - this.subagentPanelVisible = panel.classList.contains('open'); - - // Update toggle button icon - if (toggleBtn) { - toggleBtn.innerHTML = this.subagentPanelVisible ? '▼' : '▲'; // Down when open, up when collapsed - } - - if (this.subagentPanelVisible) { - this.renderSubagentPanel(); - } - } - - async loadAppSettingsFromServer(settingsPromise = null) { - try { - const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); - if (settings) { - // Extract notification prefs before merging app settings - const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings; - // Filter out display settings — these are device-specific (mobile vs desktop) - // and should not be synced from the server to avoid overriding mobile defaults. - // NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) - // are NOT display keys — they control server-side behavior and must sync from server. - const displayKeys = new Set([ - 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', - 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', - 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', - ]); - // Merge settings: non-display keys always sync from server, - // display keys only seed from server when localStorage has no value - // (prevents cross-device overwrite while fixing settings re-enabling on fresh loads) - const localSettings = this.loadAppSettingsFromStorage(); - const merged = { ...localSettings }; - for (const [key, value] of Object.entries(appSettings)) { - if (displayKeys.has(key)) { - // Display keys: only use server value as initial seed - if (!(key in localSettings)) { - merged[key] = value; - } - } else { - // Non-display keys: server always wins - merged[key] = value; - } - } - this.saveAppSettingsToStorage(merged); - - // Apply notification prefs from server if present (only if localStorage has none) - if (notificationPreferences && this.notificationManager) { - const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey()); - if (!localNotifPrefs) { - this.notificationManager.preferences = notificationPreferences; - this.notificationManager.savePreferences(); - } - } - - // Sync voice settings from server (seed localStorage if no local API key) - if (voiceSettings) { - const localVoice = localStorage.getItem('codeman-voice-settings'); - if (!localVoice || !JSON.parse(localVoice).apiKey) { - VoiceInput._saveDeepgramConfig(voiceSettings); - } - } - - // Sync respawn presets from server (server is source of truth) - if (respawnPresets && Array.isArray(respawnPresets)) { - this._serverRespawnPresets = respawnPresets; - // Also update localStorage for offline access - localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets)); - } else { - // Migration: push existing localStorage presets to server - const localPresets = localStorage.getItem('codeman-respawn-presets'); - if (localPresets) { - const parsed = JSON.parse(localPresets); - if (parsed.length > 0) { - this._serverRespawnPresets = parsed; - this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {}); - } - } - } - - // Sync run mode from server - if (runMode) { - this.runMode = runMode; - try { localStorage.setItem('codeman_runMode', runMode); } catch {} - this._applyRunMode(); - } - - return merged; - } - } catch (err) { - console.error('Failed to load settings from server:', err); - } - return this.loadAppSettingsFromStorage(); - } - - - /** - * Load subagent window states from server (or localStorage fallback). - * Called on page load to restore minimized/open window states. - */ - async loadSubagentWindowStates() { - let states = null; - - // Try server first for cross-browser sync - try { - const res = await fetch('/api/subagent-window-states'); - if (res.ok) { - states = await res.json(); - // Also update localStorage - localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states)); - } - } catch (err) { - console.error('Failed to load subagent window states from server:', err); - } - - // Fallback to localStorage - if (!states) { - try { - const saved = localStorage.getItem('codeman-subagent-window-states'); - if (saved) { - states = JSON.parse(saved); - } - } catch (err) { - console.error('Failed to load subagent window states from localStorage:', err); - } - } - - return states || { minimized: {}, open: [] }; - } - - - // ═══════════════════════════════════════════════════════════════ - // Persistent Parent Associations - // ═══════════════════════════════════════════════════════════════ - // This is the ROCK-SOLID system for tracking which tab an agent belongs to. - // Once an agent's parent is discovered, it's saved here PERMANENTLY. - - /** - * Save the subagent parent map to localStorage and server. - * Called whenever a new parent association is discovered. - */ - async saveSubagentParentMap() { - const mapData = Object.fromEntries(this.subagentParentMap); - - // Save to localStorage for instant recovery - try { - localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); - } catch (err) { - console.error('Failed to save subagent parents to localStorage:', err); - } - - // Save to server for cross-browser/session persistence - try { - await fetch('/api/subagent-parents', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mapData) - }); - } catch (err) { - console.error('Failed to save subagent parents to server:', err); - } - } - - /** - * Load the subagent parent map from server (or localStorage fallback). - * Called once on page load, before any agents are discovered. - */ - async loadSubagentParentMap() { - let mapData = null; - - // Try server first (most authoritative) - try { - const res = await fetch('/api/subagent-parents'); - if (res.ok) { - mapData = await res.json(); - // Update localStorage as cache - localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); - } - } catch (err) { - console.error('Failed to load subagent parents from server:', err); - } - - // Fallback to localStorage - if (!mapData) { - try { - const saved = localStorage.getItem('codeman-subagent-parents'); - if (saved) { - mapData = JSON.parse(saved); - } - } catch (err) { - console.error('Failed to load subagent parents from localStorage:', err); - } - } - - // Populate the map (prune stale entries: require both session and agent to exist) - if (mapData && typeof mapData === 'object') { - for (const [agentId, sessionId] of Object.entries(mapData)) { - if (this.sessions.has(sessionId) && this.subagents.has(agentId)) { - this.subagentParentMap.set(agentId, sessionId); - } - } - } - } - - /** - * Get the parent session ID for an agent from the persistent map. - * This is the ONLY source of truth for connection lines. - */ - getAgentParentSessionId(agentId) { - return this.subagentParentMap.get(agentId) || null; - } - - /** - * Set and persist the parent session ID for an agent. - * Once set, this association is PERMANENT and never recalculated. - */ - setAgentParentSessionId(agentId, sessionId) { - if (!agentId || !sessionId) return; - - // Only set if not already set (first association wins) - if (this.subagentParentMap.has(agentId)) { - return; // Already has a parent, don't override - } - - this.subagentParentMap.set(agentId, sessionId); - this.saveSubagentParentMap(); // Persist immediately - - // Also update the agent object for consistency - const agent = this.subagents.get(agentId); - if (agent) { - agent.parentSessionId = sessionId; - const session = this.sessions.get(sessionId); - if (session) { - agent.parentSessionName = this.getSessionName(session); - } - this.subagents.set(agentId, agent); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Help Modal - // ═══════════════════════════════════════════════════════════════ - - showHelp() { - const modal = document.getElementById('helpModal'); - modal.classList.add('active'); - - // Activate focus trap - this.activeFocusTrap = new FocusTrap(modal); - this.activeFocusTrap.activate(); - } - - closeHelp() { - document.getElementById('helpModal').classList.remove('active'); - - // Deactivate focus trap and restore focus - if (this.activeFocusTrap) { - this.activeFocusTrap.deactivate(); - this.activeFocusTrap = null; - } - } - - closeAllPanels() { - this.closeSessionOptions(); - this.closeAppSettings(); - this.cancelCloseSession(); - this.closeTokenStats(); - document.getElementById('monitorPanel').classList.remove('open'); - // Collapse subagents panel (don't hide it permanently) - const subagentsPanel = document.getElementById('subagentsPanel'); - if (subagentsPanel) { - subagentsPanel.classList.remove('open'); - } - this.subagentPanelVisible = false; - } - - // ═══════════════════════════════════════════════════════════════ - // Token Statistics Modal - // ═══════════════════════════════════════════════════════════════ - - async openTokenStats() { - try { - const response = await fetch('/api/token-stats'); - const data = await response.json(); - if (data.success) { - this.renderTokenStats(data); - document.getElementById('tokenStatsModal').classList.add('active'); - } else { - this.showToast('Failed to load token stats', 'error'); - } - } catch (err) { - console.error('Failed to fetch token stats:', err); - this.showToast('Failed to load token stats', 'error'); - } - } - - renderTokenStats(data) { - const { daily, totals } = data; - - // Calculate period totals - const today = new Date().toISOString().split('T')[0]; - const todayData = daily.find(d => d.date === today) || { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }; - - // Last 7 days totals (for summary card) - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - const last7Days = daily.filter(d => new Date(d.date) >= sevenDaysAgo); - const weekInput = last7Days.reduce((sum, d) => sum + d.inputTokens, 0); - const weekOutput = last7Days.reduce((sum, d) => sum + d.outputTokens, 0); - const weekCost = this.estimateCost(weekInput, weekOutput); - - // Lifetime totals (from aggregate stats) - const lifetimeInput = totals.totalInputTokens; - const lifetimeOutput = totals.totalOutputTokens; - const lifetimeCost = this.estimateCost(lifetimeInput, lifetimeOutput); - - // Render summary cards - const summaryEl = document.getElementById('statsSummary'); - summaryEl.innerHTML = ` -
- Today - ${this.formatTokens(todayData.inputTokens + todayData.outputTokens)} - ~$${todayData.estimatedCost.toFixed(2)} -
-
- 7 Days - ${this.formatTokens(weekInput + weekOutput)} - ~$${weekCost.toFixed(2)} -
-
- Lifetime - ${this.formatTokens(lifetimeInput + lifetimeOutput)} - ~$${lifetimeCost.toFixed(2)} -
- `; - - // Render bar chart (last 7 days) - const chartEl = document.getElementById('statsChart'); - const daysEl = document.getElementById('statsChartDays'); - - // Get last 7 days (fill gaps with empty data) - const chartData = []; - for (let i = 6; i >= 0; i--) { - const date = new Date(); - date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split('T')[0]; - const dayData = daily.find(d => d.date === dateStr); - chartData.push({ - date: dateStr, - dayName: date.toLocaleDateString('en-US', { weekday: 'short' }), - tokens: dayData ? dayData.inputTokens + dayData.outputTokens : 0, - cost: dayData ? dayData.estimatedCost : 0, - }); - } - - // Find max for scaling - const maxTokens = Math.max(...chartData.map(d => d.tokens), 1); - - chartEl.innerHTML = chartData.map(d => { - const height = Math.max((d.tokens / maxTokens) * 100, 3); - const tooltip = `${d.dayName}: ${this.formatTokens(d.tokens)} (~$${d.cost.toFixed(2)})`; - return `
`; - }).join(''); - - daysEl.innerHTML = chartData.map(d => `${d.dayName}`).join(''); - - // Render table (last 14 days with data) - const tableEl = document.getElementById('statsTable'); - const tableData = daily.slice(0, 14); - - if (tableData.length === 0) { - tableEl.innerHTML = '
No usage data recorded yet
'; - } else { - tableEl.innerHTML = ` -
- Date - Input - Output - Cost -
- ${tableData.map(d => { - const dateObj = new Date(d.date + 'T00:00:00'); - const dateStr = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - return ` -
- ${dateStr} - ${this.formatTokens(d.inputTokens)} - ${this.formatTokens(d.outputTokens)} - $${d.estimatedCost.toFixed(2)} -
- `; - }).join('')} - `; - } - } - - closeTokenStats() { - const modal = document.getElementById('tokenStatsModal'); - if (modal) { - modal.classList.remove('active'); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Monitor Panel (combined Mux Sessions + Background Tasks) - // ═══════════════════════════════════════════════════════════════ - - async toggleMonitorPanel() { - const panel = document.getElementById('monitorPanel'); - const toggleBtn = document.getElementById('monitorToggleBtn'); - panel.classList.toggle('open'); - - if (panel.classList.contains('open')) { - // Load screens and start stats collection - await this.loadMuxSessions(); - await fetch('/api/mux-sessions/stats/start', { method: 'POST' }); - this.renderTaskPanel(); - if (toggleBtn) toggleBtn.innerHTML = '▼'; // Down arrow when open - } else { - // Stop stats collection when panel is closed - await fetch('/api/mux-sessions/stats/stop', { method: 'POST' }); - if (toggleBtn) toggleBtn.innerHTML = '▲'; // Up arrow when closed - } - } - - // Legacy alias for task panel toggle (used by session tab badge) - toggleTaskPanel() { - this.toggleMonitorPanel(); - } - - // ═══════════════════════════════════════════════════════════════ - // Monitor Panel Detach & Drag - // ═══════════════════════════════════════════════════════════════ - - toggleMonitorDetach() { - const panel = document.getElementById('monitorPanel'); - const detachBtn = document.getElementById('monitorDetachBtn'); - - if (panel.classList.contains('detached')) { - // Re-attach to bottom - panel.classList.remove('detached'); - panel.style.top = ''; - panel.style.left = ''; - panel.style.width = ''; - panel.style.height = ''; - if (detachBtn) { - detachBtn.innerHTML = '⧉'; // Detach icon (two overlapping squares) - detachBtn.title = 'Detach panel'; - } - } else { - // Detach as floating window - panel.classList.add('detached'); - panel.classList.add('open'); // Ensure it's visible - if (detachBtn) { - detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back) - detachBtn.title = 'Attach panel'; - } - // Setup drag functionality - this.setupMonitorDrag(); - } - } - - setupMonitorDrag() { - const panel = document.getElementById('monitorPanel'); - const header = document.getElementById('monitorPanelHeader'); - - if (!panel || !header) return; - - let isDragging = false; - let startX, startY, startLeft, startTop; - - const onStart = (e) => { - // Only drag from header, not from buttons - if (e.target.closest('button')) return; - if (!panel.classList.contains('detached')) return; - - isDragging = true; - const coords = getEventCoords(e); - startX = coords.clientX; - startY = coords.clientY; - const rect = panel.getBoundingClientRect(); - startLeft = rect.left; - startTop = rect.top; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onEnd); - document.addEventListener('touchmove', onMove, { passive: false }); - document.addEventListener('touchend', onEnd); - e.preventDefault(); - }; - - const onMove = (e) => { - if (!isDragging) return; - - const coords = getEventCoords(e); - const dx = coords.clientX - startX; - const dy = coords.clientY - startY; - - let newLeft = startLeft + dx; - let newTop = startTop + dy; - - // Keep within viewport bounds - const rect = panel.getBoundingClientRect(); - newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); - newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); - - panel.style.left = newLeft + 'px'; - panel.style.top = newTop + 'px'; - }; - - const onEnd = () => { - isDragging = false; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onEnd); - document.removeEventListener('touchmove', onMove); - document.removeEventListener('touchend', onEnd); - }; - - // Remove existing listeners before adding new ones - header.removeEventListener('mousedown', header._dragHandler); - header.removeEventListener('touchstart', header._touchDragHandler); - header._dragHandler = onStart; - header._touchDragHandler = onStart; - header.addEventListener('mousedown', onStart); - header.addEventListener('touchstart', onStart, { passive: false }); - } - - // ═══════════════════════════════════════════════════════════════ - // Subagents Panel Detach & Drag - // ═══════════════════════════════════════════════════════════════ - - toggleSubagentsDetach() { - const panel = document.getElementById('subagentsPanel'); - const detachBtn = document.getElementById('subagentsDetachBtn'); - - if (panel.classList.contains('detached')) { - // Re-attach to bottom - panel.classList.remove('detached'); - panel.style.top = ''; - panel.style.left = ''; - panel.style.width = ''; - panel.style.height = ''; - if (detachBtn) { - detachBtn.innerHTML = '⧉'; // Detach icon - detachBtn.title = 'Detach panel'; - } - } else { - // Detach as floating window - panel.classList.add('detached'); - panel.classList.add('open'); // Ensure it's visible - if (detachBtn) { - detachBtn.innerHTML = '⊞'; // Attach icon - detachBtn.title = 'Attach panel'; - } - // Setup drag functionality - this.setupSubagentsDrag(); - } - } - - setupSubagentsDrag() { - const panel = document.getElementById('subagentsPanel'); - const header = document.getElementById('subagentsPanelHeader'); - - if (!panel || !header) return; - - let isDragging = false; - let startX, startY, startLeft, startTop; - - const onStart = (e) => { - // Only drag from header, not from buttons - if (e.target.closest('button')) return; - if (!panel.classList.contains('detached')) return; - - isDragging = true; - const coords = getEventCoords(e); - startX = coords.clientX; - startY = coords.clientY; - const rect = panel.getBoundingClientRect(); - startLeft = rect.left; - startTop = rect.top; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onEnd); - document.addEventListener('touchmove', onMove, { passive: false }); - document.addEventListener('touchend', onEnd); - e.preventDefault(); - }; - - const onMove = (e) => { - if (!isDragging) return; - - const coords = getEventCoords(e); - const dx = coords.clientX - startX; - const dy = coords.clientY - startY; - - let newLeft = startLeft + dx; - let newTop = startTop + dy; - - // Keep within viewport bounds - const rect = panel.getBoundingClientRect(); - newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); - newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); - - panel.style.left = newLeft + 'px'; - panel.style.top = newTop + 'px'; - }; - - const onEnd = () => { - isDragging = false; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onEnd); - document.removeEventListener('touchmove', onMove); - document.removeEventListener('touchend', onEnd); - }; - - // Remove existing listeners before adding new ones - header.removeEventListener('mousedown', header._dragHandler); - header.removeEventListener('touchstart', header._touchDragHandler); - header._dragHandler = onStart; - header._touchDragHandler = onStart; - header.addEventListener('mousedown', onStart); - header.addEventListener('touchstart', onStart, { passive: false }); - } - - renderTaskPanel() { - // Debounce renders at 100ms to prevent excessive DOM updates - if (this.renderTaskPanelTimeout) { - clearTimeout(this.renderTaskPanelTimeout); - } - this.renderTaskPanelTimeout = setTimeout(() => { - this._renderTaskPanelImmediate(); - }, 100); - } - - _renderTaskPanelImmediate() { - const session = this.sessions.get(this.activeSessionId); - const body = document.getElementById('backgroundTasksBody'); - const stats = document.getElementById('taskPanelStats'); - const section = document.getElementById('backgroundTasksSection'); - - if (!session || !session.taskTree || session.taskTree.length === 0) { - // Hide the entire section when there are no background tasks - if (section) section.style.display = 'none'; - body.innerHTML = ''; - stats.textContent = '0 tasks'; - return; - } - - // Show the section when there are tasks - if (section) section.style.display = ''; - - const taskStats = session.taskStats || { running: 0, completed: 0, failed: 0, total: 0 }; - stats.textContent = `${taskStats.running} running, ${taskStats.completed} done`; - - // Render task tree recursively - const renderTask = (task, allTasks) => { - const statusIcon = task.status === 'running' ? '' : - task.status === 'completed' ? '✓' : '✗'; - const duration = task.endTime - ? `${((task.endTime - task.startTime) / 1000).toFixed(1)}s` - : `${((Date.now() - task.startTime) / 1000).toFixed(0)}s...`; - - let childrenHtml = ''; - if (task.children && task.children.length > 0) { - childrenHtml = '
'; - for (const childId of task.children) { - // Find child task in allTasks map - const childTask = allTasks.find(t => t.id === childId); - if (childTask) { - childrenHtml += `
${renderTask(childTask, allTasks)}
`; - } - } - childrenHtml += '
'; - } - - return ` -
- ${statusIcon} -
-
${escapeHtml(task.description)}
-
- ${task.subagentType} - ${duration} -
-
-
- ${childrenHtml} - `; - }; - - // Flatten all tasks for lookup - const allTasks = this.flattenTaskTree(session.taskTree); - - // Render only root tasks (those without parents or with null parentId) - let html = '
'; - for (const task of session.taskTree) { - html += `
${renderTask(task, allTasks)}
`; - } - html += '
'; - - body.innerHTML = html; - } - - flattenTaskTree(tasks, result = []) { - for (const task of tasks) { - result.push(task); - // Children are stored as IDs, not nested objects in taskTree - // The task tree from server already has the structure we need - } - return result; - } - - // ═══════════════════════════════════════════════════════════════ - // Enhanced Ralph Wiggum Loop Panel - // ═══════════════════════════════════════════════════════════════ - - updateRalphState(sessionId, updates) { - const existing = this.ralphStates.get(sessionId) || { loop: null, todos: [] }; - const updated = { ...existing, ...updates }; - this.ralphStates.set(sessionId, updated); - - // Re-render if this is the active session - if (sessionId === this.activeSessionId) { - this.renderRalphStatePanel(); - } - } - - toggleRalphStatePanel() { - // Preserve xterm scroll position to prevent jump when panel height changes - const xtermViewport = this.terminal?.element?.querySelector('.xterm-viewport'); - const scrollTop = xtermViewport?.scrollTop; - - this.ralphStatePanelCollapsed = !this.ralphStatePanelCollapsed; - this.renderRalphStatePanel(); - - // Restore scroll position and refit terminal after layout change - requestAnimationFrame(() => { - // Restore xterm scroll position - if (xtermViewport && scrollTop !== undefined) { - xtermViewport.scrollTop = scrollTop; - } - // Refit terminal to new container size - if (this.terminal && this.fitAddon) { - this.fitAddon.fit(); - } - }); - } - - async closeRalphTracker() { - if (!this.activeSessionId) return; - - // Mark this session as explicitly closed - will stay hidden until user re-enables - this.ralphClosedSessions.add(this.activeSessionId); - - // Disable tracker via API - await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-config`, { enabled: false }); - - // Clear local state and hide panel - this.ralphStates.delete(this.activeSessionId); - this.renderRalphStatePanel(); - } - - // ═══════════════════════════════════════════════════════════════ - // @fix_plan.md Integration - // ═══════════════════════════════════════════════════════════════ - - toggleRalphMenu() { - const dropdown = document.getElementById('ralphDropdown'); - if (dropdown) { - dropdown.classList.toggle('show'); - } - } - - closeRalphMenu() { - const dropdown = document.getElementById('ralphDropdown'); - if (dropdown) { - dropdown.classList.remove('show'); - } - } - - async resetCircuitBreaker() { - if (!this.activeSessionId) return; - - try { - const response = await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {}); - const data = await response?.json(); - - if (data?.success) { - this.notificationManager?.notify({ - urgency: 'info', - category: 'circuit-breaker', - title: 'Reset', - message: 'Circuit breaker reset to CLOSED', - }); - } - } catch (error) { - console.error('Error resetting circuit breaker:', error); - } - } - - /** - * Generate @fix_plan.md content and show in a modal. - */ - async showFixPlan() { - if (!this.activeSessionId) return; - - try { - const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan`); - const data = await response.json(); - - if (!data.success) { - this.notificationManager?.notify({ - urgency: 'error', - category: 'fix-plan', - title: 'Error', - message: data.error || 'Failed to generate fix plan', - }); - return; - } - - // Show in a modal - this.showFixPlanModal(data.data.content, data.data.todoCount); - } catch (error) { - console.error('Error fetching fix plan:', error); - } - } - - /** - * Show fix plan content in a modal. - */ - showFixPlanModal(content, todoCount) { - // Create modal if it doesn't exist - let modal = document.getElementById('fixPlanModal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'fixPlanModal'; - modal.className = 'modal'; - modal.innerHTML = ` - - `; - document.body.appendChild(modal); - } - - document.getElementById('fixPlanContent').value = content; - document.getElementById('fixPlanStats').textContent = `${todoCount} tasks`; - modal.classList.add('show'); - } - - closeFixPlanModal() { - const modal = document.getElementById('fixPlanModal'); - if (modal) { - modal.classList.remove('show'); - } - } - - async copyFixPlan() { - const content = document.getElementById('fixPlanContent')?.value; - if (content) { - await navigator.clipboard.writeText(content); - this.notificationManager?.notify({ - urgency: 'info', - category: 'fix-plan', - title: 'Copied', - message: 'Fix plan copied to clipboard', - }); - } - } - - async writeFixPlanToFile() { - if (!this.activeSessionId) return; - - try { - const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/write`, { - method: 'POST', - }); - const data = await response.json(); - - if (data.success) { - this.notificationManager?.notify({ - urgency: 'info', - category: 'fix-plan', - title: 'Written', - message: `@fix_plan.md written to ${data.data.filePath}`, - }); - this.closeFixPlanModal(); - } else { - this.notificationManager?.notify({ - urgency: 'error', - category: 'fix-plan', - title: 'Error', - message: data.error || 'Failed to write file', - }); - } - } catch (error) { - console.error('Error writing fix plan:', error); - } - } - - async importFixPlanFromFile() { - if (!this.activeSessionId) return; - - try { - const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/read`, { - method: 'POST', - }); - const data = await response.json(); - - if (data.success) { - this.notificationManager?.notify({ - urgency: 'info', - category: 'fix-plan', - title: 'Imported', - message: `Imported ${data.data.importedCount} tasks from @fix_plan.md`, - }); - // Refresh ralph panel - this.updateRalphState(this.activeSessionId, { todos: data.data.todos }); - } else { - this.notificationManager?.notify({ - urgency: 'warning', - category: 'fix-plan', - title: 'Not Found', - message: data.error || '@fix_plan.md not found', - }); - } - } catch (error) { - console.error('Error importing fix plan:', error); - } - } - - toggleRalphDetach() { - const panel = this.$('ralphStatePanel'); - const detachBtn = this.$('ralphDetachBtn'); - - if (!panel) return; - - if (panel.classList.contains('detached')) { - // Re-attach to original position - panel.classList.remove('detached'); - panel.style.top = ''; - panel.style.left = ''; - panel.style.width = ''; - panel.style.height = ''; - if (detachBtn) { - detachBtn.innerHTML = '⧉'; // Detach icon (two overlapping squares) - detachBtn.title = 'Detach panel'; - } - } else { - // Detach as floating window - panel.classList.add('detached'); - // Expand when detaching for better visibility - this.ralphStatePanelCollapsed = false; - panel.classList.remove('collapsed'); - if (detachBtn) { - detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back) - detachBtn.title = 'Attach panel'; - } - // Setup drag functionality - this.setupRalphDrag(); - } - this.renderRalphStatePanel(); - } - - setupRalphDrag() { - const panel = this.$('ralphStatePanel'); - const header = this.$('ralphSummary'); - - if (!panel || !header) return; - - let isDragging = false; - let startX, startY, startLeft, startTop; - - const onMouseDown = (e) => { - // Only drag from header, not from buttons or toggle - if (e.target.closest('button') || e.target.closest('.ralph-toggle')) return; - if (!panel.classList.contains('detached')) return; - - isDragging = true; - startX = e.clientX; - startY = e.clientY; - const rect = panel.getBoundingClientRect(); - startLeft = rect.left; - startTop = rect.top; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - e.preventDefault(); - }; - - const onMouseMove = (e) => { - if (!isDragging) return; - - const dx = e.clientX - startX; - const dy = e.clientY - startY; - - let newLeft = startLeft + dx; - let newTop = startTop + dy; - - // Keep within viewport bounds - const rect = panel.getBoundingClientRect(); - newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); - newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); - - panel.style.left = newLeft + 'px'; - panel.style.top = newTop + 'px'; - }; - - const onMouseUp = () => { - isDragging = false; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - // Remove existing listeners before adding new ones - header.removeEventListener('mousedown', header._ralphDragHandler); - header._ralphDragHandler = onMouseDown; - header.addEventListener('mousedown', onMouseDown); - } - - renderRalphStatePanel() { - // Debounce renders at 50ms to prevent excessive DOM updates - if (this.renderRalphStatePanelTimeout) { - clearTimeout(this.renderRalphStatePanelTimeout); - } - this.renderRalphStatePanelTimeout = setTimeout(() => { - this._renderRalphStatePanelImmediate(); - }, 50); - } - - _renderRalphStatePanelImmediate() { - const panel = this.$('ralphStatePanel'); - const toggle = this.$('ralphToggle'); - - if (!panel) return; - - // If user explicitly closed this session's Ralph panel, keep it hidden - if (this.ralphClosedSessions.has(this.activeSessionId)) { - panel.style.display = 'none'; - return; - } - - const state = this.ralphStates.get(this.activeSessionId); - - // Check if there's anything to show - // Only show panel if tracker is enabled OR there's active state to display - const isEnabled = state?.loop?.enabled === true; - const hasLoop = state?.loop?.active || state?.loop?.completionPhrase; - const hasTodos = state?.todos?.length > 0; - const hasCircuitBreaker = state?.circuitBreaker && state.circuitBreaker.state !== 'CLOSED'; - const hasStatusBlock = state?.statusBlock !== undefined; - - if (!isEnabled && !hasLoop && !hasTodos && !hasCircuitBreaker && !hasStatusBlock) { - panel.style.display = 'none'; - return; - } - - panel.style.display = ''; - - // Calculate completion percentage - const todos = state?.todos || []; - const completed = todos.filter(t => t.status === 'completed').length; - const total = todos.length; - const percent = total > 0 ? Math.round((completed / total) * 100) : 0; - - // Update progress rings - this.updateRalphRing(percent); - - // Update status badge (pass completion info) - this.updateRalphStatus(state?.loop, completed, total); - - // Update stats - this.updateRalphStats(state?.loop, completed, total); - - // Update circuit breaker badge - this.updateCircuitBreakerBadge(state?.circuitBreaker); - - // Handle collapsed/expanded state - if (this.ralphStatePanelCollapsed) { - panel.classList.add('collapsed'); - if (toggle) toggle.innerHTML = '▼'; // Down arrow when collapsed (click to expand) - } else { - panel.classList.remove('collapsed'); - if (toggle) toggle.innerHTML = '▲'; // Up arrow when expanded (click to collapse) - - // Update expanded view content - this.updateRalphExpandedView(state); - } - } - - updateRalphRing(percent) { - // Ensure percent is a valid number between 0-100 - const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); - - // Mini ring (in summary) - const miniProgress = this.$('ralphRingMiniProgress'); - const miniText = this.$('ralphRingMiniText'); - if (miniProgress) { - // Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100 - // offset = 100 means 0% visible, offset = 0 means 100% visible - const offset = 100 - safePercent; - miniProgress.style.strokeDashoffset = offset; - } - if (miniText) { - miniText.textContent = `${safePercent}%`; - } - - // Large ring (in expanded view) - const largeProgress = this.$('ralphRingProgress'); - const largePercent = this.$('ralphRingPercent'); - if (largeProgress) { - // Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264 - // offset = 264 means 0% visible, offset = 0 means 100% visible - const offset = 264 - (264 * safePercent / 100); - largeProgress.style.strokeDashoffset = offset; - } - if (largePercent) { - largePercent.textContent = `${safePercent}%`; - } - } - - updateRalphStatus(loop, completed = 0, total = 0) { - const badge = this.$('ralphStatusBadge'); - const statusText = badge?.querySelector('.ralph-status-text'); - if (!badge || !statusText) return; - - badge.classList.remove('active', 'completed', 'tracking'); - - if (loop?.active) { - badge.classList.add('active'); - statusText.textContent = 'Running'; - } else if (total > 0 && completed === total) { - // Only show "Complete" when all todos are actually done - badge.classList.add('completed'); - statusText.textContent = 'Complete'; - } else if (loop?.enabled || total > 0) { - badge.classList.add('tracking'); - statusText.textContent = 'Tracking'; - } else { - statusText.textContent = 'Idle'; - } - } - - updateCircuitBreakerBadge(circuitBreaker) { - // Find or create the circuit breaker badge container - let cbContainer = this.$('ralphCircuitBreakerBadge'); - if (!cbContainer) { - // Create container if it doesn't exist (we'll add it dynamically) - const summary = this.$('ralphSummary'); - if (!summary) return; - - // Check if it already exists - cbContainer = summary.querySelector('.ralph-circuit-breaker'); - if (!cbContainer) { - cbContainer = document.createElement('div'); - cbContainer.id = 'ralphCircuitBreakerBadge'; - cbContainer.className = 'ralph-circuit-breaker'; - // Insert after the status badge - const statusBadge = this.$('ralphStatusBadge'); - if (statusBadge && statusBadge.nextSibling) { - statusBadge.parentNode.insertBefore(cbContainer, statusBadge.nextSibling); - } else { - summary.appendChild(cbContainer); - } - } - } - - // Hide if no circuit breaker state or CLOSED - if (!circuitBreaker || circuitBreaker.state === 'CLOSED') { - cbContainer.style.display = 'none'; - return; - } - - cbContainer.style.display = ''; - cbContainer.classList.remove('half-open', 'open'); - - if (circuitBreaker.state === 'HALF_OPEN') { - cbContainer.classList.add('half-open'); - cbContainer.innerHTML = `⚠Warning`; - cbContainer.title = circuitBreaker.reason || 'Circuit breaker warning'; - } else if (circuitBreaker.state === 'OPEN') { - cbContainer.classList.add('open'); - cbContainer.innerHTML = `🛑Stuck`; - cbContainer.title = circuitBreaker.reason || 'Loop appears stuck'; - } - - // Add click handler to reset - cbContainer.onclick = () => this.resetCircuitBreaker(); - } - - - updateRalphStats(loop, completed, total) { - // Time stat - const timeEl = this.$('ralphStatTime'); - if (timeEl) { - if (loop?.elapsedHours !== null && loop?.elapsedHours !== undefined) { - timeEl.textContent = this.formatRalphTime(loop.elapsedHours); - } else if (loop?.startedAt) { - const hours = (Date.now() - loop.startedAt) / (1000 * 60 * 60); - timeEl.textContent = this.formatRalphTime(hours); - } else { - timeEl.textContent = '0m'; - } - } - - // Cycles stat - const cyclesEl = this.$('ralphStatCycles'); - if (cyclesEl) { - if (loop?.maxIterations) { - cyclesEl.textContent = `${loop.cycleCount || 0}/${loop.maxIterations}`; - } else { - cyclesEl.textContent = String(loop?.cycleCount || 0); - } - } - - // Tasks stat - const tasksEl = this.$('ralphStatTasks'); - if (tasksEl) { - tasksEl.textContent = `${completed}/${total}`; - } - } - - formatRalphTime(hours) { - if (hours < 0.0167) return '0m'; // < 1 minute - if (hours < 1) { - const minutes = Math.round(hours * 60); - return `${minutes}m`; - } - const h = Math.floor(hours); - const m = Math.round((hours - h) * 60); - if (m === 0) return `${h}h`; - return `${h}h ${m}m`; - } - - updateRalphExpandedView(state) { - // Update phrase - const phraseEl = this.$('ralphPhrase'); - if (phraseEl) { - phraseEl.textContent = state?.loop?.completionPhrase || '--'; - } - - // Update elapsed - const elapsedEl = this.$('ralphElapsed'); - if (elapsedEl) { - if (state?.loop?.elapsedHours !== null && state?.loop?.elapsedHours !== undefined) { - elapsedEl.textContent = this.formatRalphTime(state.loop.elapsedHours); - } else if (state?.loop?.startedAt) { - const hours = (Date.now() - state.loop.startedAt) / (1000 * 60 * 60); - elapsedEl.textContent = this.formatRalphTime(hours); - } else { - elapsedEl.textContent = '0m'; - } - } - - // Update iterations - const iterationsEl = this.$('ralphIterations'); - if (iterationsEl) { - if (state?.loop?.maxIterations) { - iterationsEl.textContent = `${state.loop.cycleCount || 0} / ${state.loop.maxIterations}`; - } else { - iterationsEl.textContent = String(state?.loop?.cycleCount || 0); - } - } - - // Update tasks count - const todos = state?.todos || []; - const completed = todos.filter(t => t.status === 'completed').length; - const tasksCountEl = this.$('ralphTasksCount'); - if (tasksCountEl) { - tasksCountEl.textContent = `${completed}/${todos.length}`; - } - - // Update plan version display if available - if (state?.loop?.planVersion) { - this.updatePlanVersionDisplay(state.loop.planVersion, state.loop.planHistoryLength || 1); - } else { - this.updatePlanVersionDisplay(null, 0); - } - - // Render task cards - this.renderRalphTasks(todos); - - // Render RALPH_STATUS block if present - this.renderRalphStatusBlock(state?.statusBlock); - } - - renderRalphStatusBlock(statusBlock) { - // Find or create the status block container - let container = this.$('ralphStatusBlockDisplay'); - const expandedContent = this.$('ralphExpandedContent'); - - if (!statusBlock) { - // Remove container if no status block - if (container) { - container.remove(); - } - return; - } - - if (!container && expandedContent) { - container = document.createElement('div'); - container.id = 'ralphStatusBlockDisplay'; - container.className = 'ralph-status-block'; - // Insert at the top of expanded content - expandedContent.insertBefore(container, expandedContent.firstChild); - } - - if (!container) return; - - // Build status class - const statusClass = statusBlock.status === 'IN_PROGRESS' ? 'in-progress' - : statusBlock.status === 'COMPLETE' ? 'complete' - : statusBlock.status === 'BLOCKED' ? 'blocked' : ''; - - // Build tests status icon - const testsIcon = statusBlock.testsStatus === 'PASSING' ? '✅' - : statusBlock.testsStatus === 'FAILING' ? '❌' - : '⏸'; - - // Build work type icon - const workIcon = statusBlock.workType === 'IMPLEMENTATION' ? '🔧' - : statusBlock.workType === 'TESTING' ? '🧪' - : statusBlock.workType === 'DOCUMENTATION' ? '📝' - : statusBlock.workType === 'REFACTORING' ? '♻️' : '📋'; - - let html = ` -
- RALPH_STATUS - ${escapeHtml(statusBlock.status)} - ${statusBlock.exitSignal ? '🚪 EXIT' : ''} -
-
- ${workIcon} ${escapeHtml(statusBlock.workType)} - 📁 ${statusBlock.filesModified} files - ✓ ${escapeHtml(String(statusBlock.tasksCompletedThisLoop))} tasks - ${testsIcon} Tests: ${escapeHtml(statusBlock.testsStatus)} -
- `; - - if (statusBlock.recommendation) { - html += `
${escapeHtml(statusBlock.recommendation)}
`; - } - - container.innerHTML = html; - } - - renderRalphTasks(todos) { - const grid = this.$('ralphTasksGrid'); - if (!grid) return; - - if (todos.length === 0) { - if (grid.children.length !== 1 || !grid.querySelector('.ralph-state-empty')) { - grid.innerHTML = '
No tasks detected
'; - } - return; - } - - // Sort: by priority (P0 > P1 > P2 > null), then by status (in_progress > pending > completed) - const priorityOrder = { 'P0': 0, 'P1': 1, 'P2': 2, null: 3 }; - const statusOrder = { in_progress: 0, pending: 1, completed: 2 }; - const sorted = [...todos].sort((a, b) => { - const priA = priorityOrder[a.priority] ?? 3; - const priB = priorityOrder[b.priority] ?? 3; - if (priA !== priB) return priA - priB; - return (statusOrder[a.status] || 1) - (statusOrder[b.status] || 1); - }); - - // Always do full rebuild for enhanced features - const fragment = document.createDocumentFragment(); - - sorted.forEach((todo, idx) => { - const card = this.createRalphTaskCard(todo, idx); - fragment.appendChild(card); - }); - - grid.innerHTML = ''; - grid.appendChild(fragment); - } - - createRalphTaskCard(todo, index) { - const card = document.createElement('div'); - const statusClass = `task-${todo.status.replace('_', '-')}`; - const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : ''; - card.className = `ralph-task-card ${statusClass} ${priorityClass}`.trim(); - card.dataset.taskId = todo.id || index; - - // Status icon - const iconSpan = document.createElement('span'); - iconSpan.className = 'ralph-task-icon'; - iconSpan.textContent = this.getRalphTaskIcon(todo.status); - card.appendChild(iconSpan); - - // Priority badge if present - if (todo.priority) { - const prioritySpan = document.createElement('span'); - prioritySpan.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; - prioritySpan.textContent = todo.priority; - card.appendChild(prioritySpan); - } - - // Task content - const contentSpan = document.createElement('span'); - contentSpan.className = 'ralph-task-content'; - contentSpan.textContent = todo.content; - card.appendChild(contentSpan); - - // Attempts indicator (if > 0) - if (todo.attempts && todo.attempts > 0) { - const attemptsSpan = document.createElement('span'); - attemptsSpan.className = 'ralph-task-attempts'; - if (todo.lastError) { - attemptsSpan.classList.add('has-errors'); - attemptsSpan.title = `Last error: ${todo.lastError}`; - } - attemptsSpan.textContent = `#${todo.attempts}`; - card.appendChild(attemptsSpan); - } - - // Verification badge (if has verification criteria) - if (todo.verificationCriteria) { - const verifySpan = document.createElement('span'); - verifySpan.className = 'ralph-task-verify-badge'; - verifySpan.title = `Verify: ${todo.verificationCriteria}`; - verifySpan.textContent = '✓'; - card.appendChild(verifySpan); - } - - // Dependencies indicator - if (todo.dependencies && todo.dependencies.length > 0) { - const depsSpan = document.createElement('span'); - depsSpan.className = 'ralph-task-deps-indicator'; - depsSpan.title = `Depends on: ${todo.dependencies.join(', ')}`; - depsSpan.textContent = `↗${todo.dependencies.length}`; - card.appendChild(depsSpan); - } - - // Quick action buttons (shown on hover) - const actions = document.createElement('div'); - actions.className = 'ralph-task-actions'; - - if (todo.status !== 'completed') { - const completeBtn = document.createElement('button'); - completeBtn.className = 'ralph-task-action-btn'; - completeBtn.textContent = '✓'; - completeBtn.title = 'Mark complete'; - completeBtn.onclick = (e) => { - e.stopPropagation(); - this.updateRalphTaskStatus(todo.id, 'completed'); - }; - actions.appendChild(completeBtn); - } - - if (todo.status === 'completed') { - const reopenBtn = document.createElement('button'); - reopenBtn.className = 'ralph-task-action-btn'; - reopenBtn.textContent = '↺'; - reopenBtn.title = 'Reopen'; - reopenBtn.onclick = (e) => { - e.stopPropagation(); - this.updateRalphTaskStatus(todo.id, 'pending'); - }; - actions.appendChild(reopenBtn); - } - - if (todo.lastError) { - const retryBtn = document.createElement('button'); - retryBtn.className = 'ralph-task-action-btn'; - retryBtn.textContent = '↻'; - retryBtn.title = 'Retry (clear error)'; - retryBtn.onclick = (e) => { - e.stopPropagation(); - this.retryRalphTask(todo.id); - }; - actions.appendChild(retryBtn); - } - - card.appendChild(actions); - - return card; - } - - // Update a Ralph task's status via API - async updateRalphTaskStatus(taskId, newStatus) { - if (!this.activeSessionId) return; - - try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: newStatus }) - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to update task'); - } - - this.showToast(`Task ${newStatus === 'completed' ? 'completed' : 'reopened'}`, 'success'); - } catch (err) { - this.showToast('Failed to update task: ' + err.message, 'error'); - } - } - - // Retry a failed Ralph task (clear error, reset attempts) - async retryRalphTask(taskId) { - if (!this.activeSessionId) return; - - try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ attempts: 0, lastError: null, status: 'pending' }) - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to retry task'); - } - - this.showToast('Task reset for retry', 'success'); - } catch (err) { - this.showToast('Failed to retry task: ' + err.message, 'error'); - } - } - - getRalphTaskIcon(status) { - switch (status) { - case 'completed': return '✓'; - case 'in_progress': return '◐'; - case 'pending': - default: return '○'; - } - } - - // Legacy method for backwards compatibility - getTodoIcon(status) { - return this.getRalphTaskIcon(status); - } - - // ═══════════════════════════════════════════════════════════════ - // Plan Versioning - // ═══════════════════════════════════════════════════════════════ - - // Update the plan version display in the Ralph panel - updatePlanVersionDisplay(version, historyLength) { - const versionRow = this.$('ralphVersionRow'); - const versionBadge = this.$('ralphPlanVersion'); - const rollbackBtn = this.$('ralphRollbackBtn'); - - if (!versionRow) return; - - if (version && version > 0) { - versionRow.style.display = ''; - if (versionBadge) versionBadge.textContent = `v${version}`; - if (rollbackBtn) { - rollbackBtn.style.display = historyLength > 1 ? '' : 'none'; - } - } else { - versionRow.style.display = 'none'; - } - } - - // Show plan history dropdown - async showPlanHistory() { - if (!this.activeSessionId) return; - - try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/history`); - const data = await res.json(); - - if (data.error) { - this.showToast('Failed to load plan history: ' + data.error, 'error'); - return; - } - - const history = data.history || []; - if (history.length === 0) { - this.showToast('No plan history available', 'info'); - return; - } - - // Show history dropdown modal - this.showPlanHistoryModal(history, data.currentVersion); - } catch (err) { - this.showToast('Failed to load plan history: ' + err.message, 'error'); - } - } - - // Show the plan history modal - showPlanHistoryModal(history, currentVersion) { - // Remove existing modal if present - const existing = document.getElementById('planHistoryModal'); - if (existing) existing.remove(); - - const modal = document.createElement('div'); - modal.id = 'planHistoryModal'; - modal.className = 'modal active'; - modal.innerHTML = ` - - - `; - - document.body.appendChild(modal); - } - - closePlanHistoryModal() { - const modal = document.getElementById('planHistoryModal'); - if (modal) modal.remove(); - } - - // Rollback to a specific plan version - async rollbackToPlanVersion(version) { - if (!this.activeSessionId) return; - - if (!confirm(`Rollback to plan version ${version}? Current changes will be preserved in history.`)) { - return; - } - - try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/rollback/${version}`, { - method: 'POST' - }); - const data = await res.json(); - - if (data.error) { - this.showToast('Failed to rollback: ' + data.error, 'error'); - return; - } - - this.showToast(`Rolled back to plan v${version}`, 'success'); - this.closePlanHistoryModal(); - - // Refresh the plan display - this.renderRalphStatePanel(); - } catch (err) { - this.showToast('Failed to rollback: ' + err.message, 'error'); - } - } - - // Format relative time (e.g., "2 mins ago", "1 hour ago") - formatRelativeTime(timestamp) { - if (!timestamp) return ''; - - const now = Date.now(); - const diff = now - timestamp; - - const mins = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - if (hours < 24) return `${hours}h ago`; - return `${days}d ago`; - } - - // ═══════════════════════════════════════════════════════════════ - // Subagent Panel (Claude Code Background Agents) - // ═══════════════════════════════════════════════════════════════ - - // Legacy alias - toggleSubagentPanel() { - this.toggleSubagentsPanel(); - } - - updateSubagentBadge() { - const badge = this.$('subagentCountBadge'); - const activeCount = Array.from(this.subagents.values()).filter(s => s.status === 'active' || s.status === 'idle').length; - - // Update badge with active count - if (badge) { - badge.textContent = activeCount > 0 ? activeCount : ''; - } - } - - renderSubagentPanel() { - // Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events - if (this._subagentPanelRenderTimeout) { - clearTimeout(this._subagentPanelRenderTimeout); - } - this._subagentPanelRenderTimeout = setTimeout(() => { - scheduleBackground(() => this._renderSubagentPanelImmediate()); - }, 150); - } - - _renderSubagentPanelImmediate() { - const list = this.$('subagentList'); - if (!list) return; - - // Always update badge count - this.updateSubagentBadge(); - - // Always update monitor panel (even if subagent panel is hidden) - this.renderMonitorSubagents(); - - // If panel is not visible, don't render content - if (!this.subagentPanelVisible) { - return; - } - - // Render subagent list - if (this.subagents.size === 0) { - list.innerHTML = '
No background agents detected
'; - return; - } - - const html = []; - const sorted = Array.from(this.subagents.values()).sort((a, b) => { - // Active first, then by last activity - if (a.status === 'active' && b.status !== 'active') return -1; - if (b.status === 'active' && a.status !== 'active') return 1; - return (b.lastActivityAt || 0) - (a.lastActivityAt || 0); - }); - - for (const agent of sorted) { - const isActive = this.activeSubagentId === agent.agentId; - const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed'; - const activity = this.subagentActivity.get(agent.agentId) || []; - const lastActivity = activity[activity.length - 1]; - const lastTool = lastActivity?.type === 'tool' ? lastActivity.tool : null; - const hasWindow = this.subagentWindows.has(agent.agentId); - const canKill = agent.status === 'active' || agent.status === 'idle'; - const modelBadge = agent.modelShort - ? `${escapeHtml(agent.modelShort)}` - : ''; - - const teammateInfo = this.getTeammateInfo(agent); - const displayName = teammateInfo ? teammateInfo.name : (agent.description || agent.agentId.substring(0, 7)); - const teammateBadge = this.getTeammateBadgeHtml(agent); - const agentIcon = teammateInfo ? `●` : '🤖'; - html.push(` -
-
- ${agentIcon} - ${escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)} - ${teammateBadge} - ${modelBadge} - ${agent.status} - ${canKill ? `` : ''} - -
-
- ${agent.toolCallCount} tools - ${lastTool ? `${this.getToolIcon(lastTool)} ${lastTool}` : ''} -
-
- `); - } - - list.innerHTML = html.join(''); - } - - selectSubagent(agentId) { - this.activeSubagentId = agentId; - this.renderSubagentPanel(); - this.renderSubagentDetail(); - } - - renderSubagentDetail() { - const detail = this.$('subagentDetail'); - if (!detail) return; - - if (!this.activeSubagentId) { - detail.innerHTML = '
Select an agent to view details
'; - return; - } - - const agent = this.subagents.get(this.activeSubagentId); - const activity = this.subagentActivity.get(this.activeSubagentId) || []; - - if (!agent) { - detail.innerHTML = '
Agent not found
'; - return; - } - - const activityHtml = activity.slice(-30).map(a => { - const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); - if (a.type === 'tool') { - const toolDetail = this.getToolDetailExpanded(a.tool, a.input, a.fullInput, a.toolUseId); - return `
- ${time} - ${this.getToolIcon(a.tool)} - ${a.tool} - ${toolDetail.primary} - ${toolDetail.hasMore ? `` : ''} - ${toolDetail.hasMore ? `` : ''} -
`; - } else if (a.type === 'tool_result') { - const icon = a.isError ? '❌' : '📄'; - const statusClass = a.isError ? 'error' : ''; - const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; - const preview = a.preview.length > 80 ? a.preview.substring(0, 80) + '...' : a.preview; - return `
- ${time} - ${icon} - ${a.tool || 'result'} - ${escapeHtml(preview)}${sizeInfo} -
`; - } else if (a.type === 'progress') { - // Check for hook events - const isHook = a.hookEvent || a.hookName; - const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); - const hookClass = isHook ? ' hook' : ''; - const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); - return `
- ${time} - ${icon} - ${displayText} -
`; - } else if (a.type === 'message') { - const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text; - return `
- ${time} - 💬 - ${escapeHtml(preview)} -
`; - } - return ''; - }).join(''); - - const detailTitle = agent.description || `Agent ${agent.agentId}`; - const modelBadge = agent.modelShort - ? `${escapeHtml(agent.modelShort)}` - : ''; - const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens) - ? `Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑` - : ''; - - detail.innerHTML = ` -
- ${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)} - ${modelBadge} - ${agent.status} - -
-
- Tools: ${agent.toolCallCount} - Entries: ${agent.entryCount} - Size: ${(agent.fileSize / 1024).toFixed(1)}KB - ${tokenStats} -
-
- ${activityHtml || '
No activity yet
'} -
- `; - } - - toggleToolParams(toolUseId) { - const el = document.getElementById(`tool-params-${toolUseId}`); - if (!el) return; - const btn = el.previousElementSibling; - if (el.style.display === 'none') { - el.style.display = 'block'; - if (btn) btn.textContent = '▼'; - } else { - el.style.display = 'none'; - if (btn) btn.textContent = '▶'; - } - } - - formatTokenCount(count) { - if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; - if (count >= 1000) return (count / 1000).toFixed(1) + 'k'; - return count.toString(); - } - - formatBytes(bytes) { - if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'; - if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB'; - return bytes + 'B'; - } - - getToolIcon(tool) { - const icons = { - WebSearch: '🔍', - WebFetch: '🌐', - Read: '📖', - Write: '📝', - Edit: '✏️', - Bash: '💻', - Glob: '📁', - Grep: '🔎', - Task: '🤖', - }; - return icons[tool] || '🔧'; - } - - getToolDetail(tool, input) { - if (!input) return ''; - if (tool === 'WebSearch' && input.query) return `"${input.query}"`; - if (tool === 'WebFetch' && input.url) return input.url; - if (tool === 'Read' && input.file_path) return input.file_path; - if ((tool === 'Write' || tool === 'Edit') && input.file_path) return input.file_path; - if (tool === 'Bash' && input.command) { - const cmd = input.command; - return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd; - } - if (tool === 'Glob' && input.pattern) return input.pattern; - if (tool === 'Grep' && input.pattern) return input.pattern; - return ''; - } - - getToolDetailExpanded(tool, input, fullInput, toolUseId) { - const primary = this.getToolDetail(tool, input); - // Check if there are additional params beyond the primary one - const primaryKeys = ['query', 'url', 'file_path', 'command', 'pattern']; - const inputKeys = Object.keys(fullInput || input || {}); - const extraKeys = inputKeys.filter(k => !primaryKeys.includes(k)); - const hasMore = extraKeys.length > 0 || (fullInput && JSON.stringify(fullInput).length > 100); - return { primary, hasMore, fullInput: fullInput || input }; - } - - async killSubagent(agentId) { - try { - const res = await this._apiDelete(`/api/subagents/${agentId}`); - const data = await res?.json(); - if (data?.success) { - // Update local state - const agent = this.subagents.get(agentId); - if (agent) { - agent.status = 'completed'; - this.subagents.set(agentId, agent); - } - this.renderSubagentPanel(); - this.renderSubagentDetail(); - this.updateSubagentWindows(); - this.showToast(`Subagent ${agentId.substring(0, 7)} killed`, 'success'); - } else { - this.showToast(data.error || 'Failed to kill subagent', 'error'); - } - } catch (err) { - console.error('Failed to kill subagent:', err); - this.showToast('Failed to kill subagent: ' + err.message, 'error'); - } - } - - async viewSubagentTranscript(agentId) { - try { - const res = await fetch(`/api/subagents/${agentId}/transcript?format=formatted`); - const data = await res.json(); - - if (!data.success) { - alert('Failed to load transcript'); - return; - } - - // Show in a modal or new window - const content = data.data.formatted.join('\n'); - const win = window.open('', '_blank', 'width=800,height=600'); - win.document.write(` - - - Subagent ${escapeHtml(agentId)} Transcript - - - -

Subagent ${escapeHtml(agentId)} Transcript (${data.data.entryCount} entries)

-
${escapeHtml(content)}
- - - `); - } catch (err) { - console.error('Failed to load transcript:', err); - alert('Failed to load transcript: ' + err.message); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Subagent Parent TAB Tracking - // ═══════════════════════════════════════════════════════════════ - // - // CRITICAL: This system tracks which TAB an agent window connects to. - // The association is stored in `subagentParentMap` (agentId -> sessionId). - // The sessionId IS the tab identifier (tabs have data-id="${sessionId}"). - // Once set, this association is PERMANENT and persisted across restarts. - - /** - * Find and assign the parent TAB for a subagent. - * - * Matching strategy (in order): - * 1. Use existing stored association from subagentParentMap (permanent) - * 2. Match via claudeSessionId (agent.sessionId === session.claudeSessionId) - * 3. FALLBACK: Use the currently active session (since that's where the user typed the command) - * - * Once found, the association is stored PERMANENTLY in subagentParentMap. - */ - findParentSessionForSubagent(agentId) { - // Check if we already have a permanent association - if (this.subagentParentMap.has(agentId)) { - // Already have a parent - update agent object from stored value - const storedSessionId = this.subagentParentMap.get(agentId); - // Verify the session still exists - if (this.sessions.has(storedSessionId)) { - const agent = this.subagents.get(agentId); - if (agent && !agent.parentSessionId) { - agent.parentSessionId = storedSessionId; - const session = this.sessions.get(storedSessionId); - if (session) { - agent.parentSessionName = this.getSessionName(session); - } - this.subagents.set(agentId, agent); - this.updateSubagentWindowParent(agentId); - } - return; - } - // Stored session no longer exists - clear and re-discover - this.subagentParentMap.delete(agentId); - } - - const agent = this.subagents.get(agentId); - if (!agent) return; - - // Strategy 1: Match via claudeSessionId (most accurate) - if (agent.sessionId) { - for (const [sessionId, session] of this.sessions) { - if (session.claudeSessionId === agent.sessionId) { - // FOUND! Store this association PERMANENTLY - this.setAgentParentSessionId(agentId, sessionId); - this.updateSubagentWindowParent(agentId); - this.updateSubagentWindowVisibility(); - this.updateConnectionLines(); - return; - } - } - } - - // Strategy 2: FALLBACK - Use the currently active session - // This works because agents spawn from where the user typed the command - if (this.activeSessionId && this.sessions.has(this.activeSessionId)) { - this.setAgentParentSessionId(agentId, this.activeSessionId); - this.updateSubagentWindowParent(agentId); - this.updateSubagentWindowVisibility(); - this.updateConnectionLines(); - return; - } - - // Strategy 3: If no active session, use the first session - if (this.sessions.size > 0) { - const firstSessionId = this.sessions.keys().next().value; - this.setAgentParentSessionId(agentId, firstSessionId); - this.updateSubagentWindowParent(agentId); - this.updateSubagentWindowVisibility(); - this.updateConnectionLines(); - } - } - - /** - * Re-check all orphan subagents (those without a parent TAB) when a session updates. - * Called when session:updated fires with claudeSessionId. - * - * Also re-validates existing associations when claudeSessionId becomes available, - * in case the fallback association was wrong. - */ - recheckOrphanSubagents() { - let anyChanged = false; - for (const [agentId, agent] of this.subagents) { - // Check if this agent has no parent in the persistent map - if (!this.subagentParentMap.has(agentId)) { - this.findParentSessionForSubagent(agentId); - if (this.subagentParentMap.has(agentId)) { - anyChanged = true; - } - } else if (agent.sessionId) { - // Agent has a stored parent, but check if we can now do a proper claudeSessionId match - // This handles the case where fallback was used but now the real parent is known - const storedParent = this.subagentParentMap.get(agentId); - const storedSession = this.sessions.get(storedParent); - - // If the stored session doesn't have a matching claudeSessionId, try to find the real match - if (storedSession && storedSession.claudeSessionId !== agent.sessionId) { - for (const [sessionId, session] of this.sessions) { - if (session.claudeSessionId === agent.sessionId) { - // Found the real parent - update the association - this.subagentParentMap.set(agentId, sessionId); - agent.parentSessionId = sessionId; - agent.parentSessionName = this.getSessionName(session); - this.subagents.set(agentId, agent); - this.updateSubagentWindowParent(agentId); - anyChanged = true; - break; - } - } - } - } - } - if (anyChanged) { - this.saveSubagentParentMap(); - this.updateConnectionLines(); - } - } - - /** - * Update parentSessionName for all subagents belonging to a TAB. - * Called when a session is renamed to keep cached names fresh. - */ - updateSubagentParentNames(sessionId) { - const session = this.sessions.get(sessionId); - if (!session) return; - - const newName = this.getSessionName(session); - - // Skip iteration if name hasn't changed (avoids O(n) loop on every session:updated) - const cachedName = this._parentNameCache?.get(sessionId); - if (cachedName === newName) return; - if (!this._parentNameCache) this._parentNameCache = new Map(); - this._parentNameCache.set(sessionId, newName); - - for (const [agentId, storedSessionId] of this.subagentParentMap) { - if (storedSessionId === sessionId) { - const agent = this.subagents.get(agentId); - if (agent) { - agent.parentSessionName = newName; - this.subagents.set(agentId, agent); - - // Update the window header if open - const windowData = this.subagentWindows.get(agentId); - if (windowData) { - const parentNameEl = windowData.element.querySelector('.subagent-window-parent .parent-name'); - if (parentNameEl) { - parentNameEl.textContent = newName; - } - } - } - } - } - } - - /** - * Add parent header to an agent window, showing which TAB it belongs to. - */ - updateSubagentWindowParent(agentId) { - const windowData = this.subagentWindows.get(agentId); - if (!windowData) return; - - // Get parent from persistent map (THE source of truth) - const parentSessionId = this.subagentParentMap.get(agentId); - if (!parentSessionId) return; - - const session = this.sessions.get(parentSessionId); - const parentName = session ? this.getSessionName(session) : 'Unknown'; - - // Check if parent header already exists - const win = windowData.element; - const existingParent = win.querySelector('.subagent-window-parent'); - if (existingParent) { - // Update existing - existingParent.dataset.parentSession = parentSessionId; - const nameEl = existingParent.querySelector('.parent-name'); - if (nameEl) { - nameEl.textContent = parentName; - nameEl.onclick = () => this.selectSession(parentSessionId); - } - return; - } - - // Insert new parent header after the main header - const header = win.querySelector('.subagent-window-header'); - if (header) { - const parentDiv = document.createElement('div'); - parentDiv.className = 'subagent-window-parent'; - parentDiv.dataset.parentSession = parentSessionId; - parentDiv.innerHTML = ` - from - ${escapeHtml(parentName)} - `; - header.insertAdjacentElement('afterend', parentDiv); - } - } - - - /** - * Show/hide subagent windows based on active session. - * Behavior controlled by "Subagents for Active Tab Only" setting. - * Uses the PERSISTENT subagentParentMap for accurate tab-based visibility. - */ - updateSubagentWindowVisibility() { - const settings = this.loadAppSettingsFromStorage(); - const activeTabOnly = settings.subagentActiveTabOnly ?? true; - - for (const [agentId, windowInfo] of this.subagentWindows) { - // Get parent from PERSISTENT map (THE source of truth) - const storedParent = this.subagentParentMap.get(agentId); - const agent = this.subagents.get(agentId); - const parentSessionId = storedParent || agent?.parentSessionId; - - // Determine visibility based on setting - let shouldShow; - if (activeTabOnly) { - // Show if: no parent known yet, or parent matches active session - const hasKnownParent = !!parentSessionId; - shouldShow = !hasKnownParent || parentSessionId === this.activeSessionId; - } else { - // Show all windows (original behavior) - shouldShow = true; - } - - if (shouldShow) { - // Show window (unless it was minimized by user) - if (!windowInfo.minimized) { - windowInfo.element.style.display = 'flex'; - // Lazily re-create teammate terminal if it was disposed when hidden - if (windowInfo._lazyTerminal) { - this._restoreTeammateTerminalFromLazy(agentId); - } - } - windowInfo.hidden = false; - } else { - // Hide window (but don't close it) - // Dispose teammate terminal to free memory while hidden on inactive tab - this._disposeTeammateTerminalForMinimize(agentId); - windowInfo.element.style.display = 'none'; - windowInfo.hidden = true; - } - } - // Update connection lines after visibility changes - this.updateConnectionLines(); - // Restack mobile windows after visibility changes - this.relayoutMobileSubagentWindows(); - } - - - // Close all subagent windows for a session (fully removes them, not minimize) - // If cleanupData is true, also remove activity and toolResults data to prevent memory leaks - closeSessionSubagentWindows(sessionId, cleanupData = false) { - const toClose = []; - for (const [agentId, _windowData] of this.subagentWindows) { - const agent = this.subagents.get(agentId); - // Check both subagent parentSessionId and subagentParentMap - // (standalone pane windows use subagentParentMap, not subagents map) - const parentFromMap = this.subagentParentMap.get(agentId); - if (agent?.parentSessionId === sessionId || parentFromMap === sessionId) { - toClose.push(agentId); - } - } - for (const agentId of toClose) { - this.forceCloseSubagentWindow(agentId); - // Clean up activity and tool results data if requested (prevents memory leaks) - if (cleanupData) { - this.subagents.delete(agentId); - this.subagentActivity.delete(agentId); - this.subagentToolResults.delete(agentId); - this.subagentParentMap.delete(agentId); - } - } - // Also clean up minimized agents for this session - this.minimizedSubagents.delete(sessionId); - this.renderSessionTabs(); - } - - // Fully close a subagent window (removes from DOM, not minimize) - forceCloseSubagentWindow(agentId) { - const windowData = this.subagentWindows.get(agentId); - if (windowData) { - // Clean up resize observer - if (windowData.resizeObserver) { - windowData.resizeObserver.disconnect(); - } - // Clean up drag event listeners (both document-level and handle-level) - 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); - } - // Remove handle-level listeners before DOM removal - if (windowData.dragListeners.handle) { - windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown); - windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart); - } - } - windowData.element.remove(); - this.subagentWindows.delete(agentId); - } - // Clean up teammate terminal if present - const termData = this.teammateTerminals.get(agentId); - if (termData) { - if (termData.resizeObserver) { - termData.resizeObserver.disconnect(); - } - if (termData.terminal) { - try { termData.terminal.dispose(); } catch {} - } - this.teammateTerminals.delete(agentId); - } - } - - - minimizeSubagentWindow(agentId) { - const windowData = this.subagentWindows.get(agentId); - if (windowData) { - // Dispose teammate terminal on minimize to free DOM/memory (lazy re-creation on restore) - this._disposeTeammateTerminalForMinimize(agentId); - windowData.element.style.display = 'none'; - windowData.minimized = true; - this.updateConnectionLines(); - } - } - - - // Debounced wrapper — coalesces rapid subagent events (tool_call, progress, - // message) into a single DOM update per 100ms per agent window. - scheduleSubagentWindowRender(agentId) { - // Skip DOM updates for windows with lazy (disposed) terminals — they're minimized - const windowData = this.subagentWindows.get(agentId); - if (windowData?.minimized) return; - - if (!this._subagentWindowRenderTimeouts) this._subagentWindowRenderTimeouts = new Map(); - if (this._subagentWindowRenderTimeouts.has(agentId)) { - clearTimeout(this._subagentWindowRenderTimeouts.get(agentId)); - } - this._subagentWindowRenderTimeouts.set(agentId, setTimeout(() => { - this._subagentWindowRenderTimeouts.delete(agentId); - scheduleBackground(() => this.renderSubagentWindowContent(agentId)); - }, 100)); - } - - renderSubagentWindowContent(agentId) { - // Skip if this window has a live terminal (don't overwrite xterm with activity HTML) - if (this.teammateTerminals.has(agentId)) return; - // Skip if this window has a lazy (disposed) terminal — it will be re-created on restore - const windowData = this.subagentWindows.get(agentId); - if (windowData?._lazyTerminal) return; - - const body = document.getElementById(`subagent-window-body-${agentId}`); - if (!body) return; - - const activity = this.subagentActivity.get(agentId) || []; - - if (activity.length === 0) { - body.innerHTML = '
No activity yet
'; - return; - } - - // Incremental rendering: track how many items are already rendered - const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0; - const maxItems = 100; - const visibleActivity = activity.slice(-maxItems); - - // If activity was trimmed or this is a fresh render, do full rebuild - if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 || - (body.children.length === 1 && body.querySelector('.subagent-empty'))) { - // Full rebuild - const html = visibleActivity.map(a => this._renderActivityItem(a)).join(''); - body.innerHTML = html; - body.dataset.renderedCount = String(visibleActivity.length); - } else { - // Incremental: only append new items - const newItems = visibleActivity.slice(renderedCount); - if (newItems.length > 0) { - const newHtml = newItems.map(a => this._renderActivityItem(a)).join(''); - body.insertAdjacentHTML('beforeend', newHtml); - body.dataset.renderedCount = String(visibleActivity.length); - - // Trim excess children from the front if over maxItems - while (body.children.length > maxItems) { - body.removeChild(body.firstChild); - } - } - } - - body.scrollTop = body.scrollHeight; - } - - _renderActivityItem(a) { - const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); - if (a.type === 'tool') { - return `
- ${time} - ${this.getToolIcon(a.tool)} - ${a.tool} - ${escapeHtml(this.getToolDetail(a.tool, a.input))} -
`; - } else if (a.type === 'tool_result') { - const icon = a.isError ? '❌' : '📄'; - const statusClass = a.isError ? ' error' : ''; - const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; - const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview; - return `
- ${time} - ${icon} - ${a.tool || '→'} - ${escapeHtml(preview)}${sizeInfo} -
`; - } else if (a.type === 'progress') { - const isHook = a.hookEvent || a.hookName; - const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); - const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); - return `
- ${time} - ${icon} - ${escapeHtml(displayText)} -
`; - } else if (a.type === 'message') { - const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; - return `
- ${time} 💬 ${escapeHtml(preview)} -
`; - } - return ''; - } - - // Update all open subagent windows - updateSubagentWindows() { - for (const agentId of this.subagentWindows.keys()) { - this.renderSubagentWindowContent(agentId); - this.updateSubagentWindowHeader(agentId); - } - } - - // Update subagent window header (title and status) - updateSubagentWindowHeader(agentId) { - const agent = this.subagents.get(agentId); - if (!agent) return; - - const win = document.getElementById(`subagent-window-${agentId}`); - if (!win) return; - - // Update title/id element with description if available - const idEl = win.querySelector('.subagent-window-title .id'); - if (idEl) { - const teammateInfo = this.getTeammateInfo(agent); - const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7)); - const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle; - idEl.textContent = truncatedTitle; - } - - // Add or update teammate badge - let tmBadge = win.querySelector('.teammate-badge'); - const teammateInfo = this.getTeammateInfo(agent); - if (teammateInfo && !tmBadge) { - const titleContainer = win.querySelector('.subagent-window-title'); - if (titleContainer) { - const badge = document.createElement('span'); - badge.className = `teammate-badge teammate-color-${teammateInfo.color}`; - badge.title = `Team: ${teammateInfo.teamName}`; - badge.textContent = `@${teammateInfo.name}`; - const statusEl = titleContainer.querySelector('.status'); - if (statusEl) statusEl.insertAdjacentElement('beforebegin', badge); - } - } - - // Update full tooltip - const titleContainer = win.querySelector('.subagent-window-title'); - if (titleContainer) { - titleContainer.title = agent.description || agentId; - } - - // Update or add model badge - let modelBadge = win.querySelector('.subagent-window-title .subagent-model-badge'); - if (agent.modelShort) { - if (!modelBadge) { - modelBadge = document.createElement('span'); - modelBadge.className = `subagent-model-badge ${agent.modelShort}`; - const statusEl = win.querySelector('.subagent-window-title .status'); - if (statusEl) { - statusEl.insertAdjacentElement('beforebegin', modelBadge); - } - } - modelBadge.className = `subagent-model-badge ${agent.modelShort}`; - modelBadge.textContent = agent.modelShort; - } - - // Update status - const statusEl = win.querySelector('.subagent-window-title .status'); - if (statusEl) { - statusEl.className = `status ${agent.status}`; - statusEl.textContent = agent.status; - } - } - - // Open windows for all active subagents - openAllActiveSubagentWindows() { - for (const [agentId, agent] of this.subagents) { - if (agent.status === 'active' && !this.subagentWindows.has(agentId)) { - this.openSubagentWindow(agentId); - } - } - } - - // ═══════════════════════════════════════════════════════════════ - // Agent Teams - // ═══════════════════════════════════════════════════════════════ - - /** Initialize an xterm.js terminal for a teammate's tmux pane */ - initTeammateTerminal(agentId, paneInfo, windowElement) { - const body = windowElement.querySelector('.subagent-window-body'); - if (!body) return; - - // Clear the activity log content - body.innerHTML = ''; - body.classList.add('teammate-terminal-body'); - windowElement.classList.add('has-terminal'); - - const sessionId = paneInfo.sessionId; - - // Buffer incoming terminal data until xterm is ready - const pendingData = []; - this.teammateTerminals.set(agentId, { - terminal: null, - fitAddon: null, - paneTarget: paneInfo.paneTarget, - sessionId, - resizeObserver: null, - pendingData, - }); - - // Defer terminal creation to next frame so the body element has computed dimensions - requestAnimationFrame(() => { - // Safety: if window was closed before we got here, bail out - if (!document.contains(body)) { - this.teammateTerminals.delete(agentId); - return; - } - - const terminal = new Terminal({ - theme: { - background: '#0d0d0d', - foreground: '#e0e0e0', - cursor: '#e0e0e0', - cursorAccent: '#0d0d0d', - selection: 'rgba(255, 255, 255, 0.3)', - black: '#0d0d0d', - red: '#ff6b6b', - green: '#51cf66', - yellow: '#ffd43b', - blue: '#339af0', - magenta: '#cc5de8', - cyan: '#22b8cf', - white: '#e0e0e0', - brightBlack: '#495057', - brightRed: '#ff8787', - brightGreen: '#69db7c', - brightYellow: '#ffe066', - brightBlue: '#5c7cfa', - brightMagenta: '#da77f2', - brightCyan: '#66d9e8', - brightWhite: '#ffffff', - }, - fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', - fontSize: 12, - lineHeight: 1.2, - cursorBlink: true, - cursorStyle: 'block', - scrollback: 5000, - allowTransparency: true, - allowProposedApi: true, - }); - - const fitAddon = new FitAddon.FitAddon(); - terminal.loadAddon(fitAddon); - - if (typeof Unicode11Addon !== 'undefined') { - try { - const unicode11Addon = new Unicode11Addon.Unicode11Addon(); - terminal.loadAddon(unicode11Addon); - terminal.unicode.activeVersion = '11'; - } catch (_e) { /* Unicode11 addon failed */ } - } - - try { - terminal.open(body); - } catch (err) { - console.warn('[TeammateTerminal] Failed to open terminal:', err); - this.teammateTerminals.delete(agentId); - return; - } - - // Wait for terminal renderer to fully initialize before any writes. - // xterm.js needs a few frames after open() before write() is safe. - setTimeout(() => { - try { fitAddon.fit(); } catch {} - - // Fetch initial pane buffer - fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`) - .then(r => r.json()) - .then(resp => { - if (resp.success && resp.data?.buffer) { - try { terminal.write(resp.data.buffer); } catch {} - } - }) - .catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err)); - - // Flush any data that arrived while terminal was initializing - for (const chunk of pendingData) { - try { terminal.write(chunk); } catch {} - } - pendingData.length = 0; - }, 100); - - // Forward keyboard input to the teammate's pane - terminal.onData((data) => { - fetch(`/api/sessions/${sessionId}/teammate-pane-input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }), - }).catch(err => console.error('[TeammateTerminal] Failed to send input:', err)); - }); - - // Resize observer to refit terminal when window is resized - const resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} }); - }); - resizeObserver.observe(body); - - // Update the stored entry with the real terminal - const entry = this.teammateTerminals.get(agentId); - if (entry) { - entry.terminal = terminal; - entry.fitAddon = fitAddon; - entry.resizeObserver = resizeObserver; - } - }); - } - - /** Open a standalone terminal window for a tmux-pane teammate (no subagent entry needed) */ - openTeammateTerminalWindow(paneData) { - // Only open if the session has a tab in Codeman - if (!this.sessions.has(paneData.sessionId)) return; - - // Use pane target as the unique ID for this window - const windowId = `pane-${paneData.paneTarget}`; - - // If window already exists, focus it - if (this.subagentWindows.has(windowId)) { - const existing = this.subagentWindows.get(windowId); - if (existing.hidden) { - existing.element.style.display = 'flex'; - existing.hidden = false; - } - existing.element.style.zIndex = ++this.subagentWindowZIndex; - if (existing.minimized) { - this.restoreSubagentWindow(windowId); - } - return; - } - - // Calculate position - const windowCount = this.subagentWindows.size; - const windowWidth = 550; - const windowHeight = 400; - const gap = 20; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const startX = 50; - const startY = 120; - const 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; - let finalX = startX + col * (windowWidth + gap); - let finalY = startY + row * (windowHeight + gap); - finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); - finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); - - // Color badge - const colorClass = paneData.color || 'blue'; - - // Create window element - const win = document.createElement('div'); - win.className = 'subagent-window has-terminal'; - win.id = `subagent-window-${windowId}`; - win.style.zIndex = ++this.subagentWindowZIndex; - win.style.left = `${finalX}px`; - win.style.top = `${finalY}px`; - win.style.width = `${windowWidth}px`; - win.style.height = `${windowHeight}px`; - win.innerHTML = ` -
-
- ⬤ - ${escapeHtml(paneData.teammateName)} - terminal -
-
- -
-
-
-
- `; - - document.body.appendChild(win); - - // Make draggable - const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); - - // Make resizable if method exists - if (typeof this.makeWindowResizable === 'function') { - this.makeWindowResizable(win); - } - - // Check visibility based on active session - const settings = this.loadAppSettingsFromStorage(); - const activeTabOnly = settings.subagentActiveTabOnly ?? true; - const shouldHide = activeTabOnly && paneData.sessionId !== this.activeSessionId; - - // Store reference - this.subagentWindows.set(windowId, { - element: win, - minimized: false, - hidden: shouldHide, - dragListeners, - description: `Teammate: ${paneData.teammateName}`, - }); - - // Also add to subagentParentMap for tab-based visibility - this.subagentParentMap.set(windowId, paneData.sessionId); - - if (shouldHide) { - win.style.display = 'none'; - } - - // Focus on click - win.addEventListener('mousedown', () => { - win.style.zIndex = ++this.subagentWindowZIndex; - }); - - // Resize observer for connection lines - const resizeObserver = new ResizeObserver(() => { - this.updateConnectionLines(); - }); - resizeObserver.observe(win); - this.subagentWindows.get(windowId).resizeObserver = resizeObserver; - - // Init the xterm.js terminal (lazy if hidden) - if (shouldHide) { - // Window starts hidden — defer terminal creation until visible (lazy init) - const windowEntry = this.subagentWindows.get(windowId); - if (windowEntry) { - windowEntry._lazyTerminal = true; - windowEntry._lazyPaneTarget = paneData.paneTarget; - windowEntry._lazySessionId = paneData.sessionId; - } - } else { - this.initTeammateTerminal(windowId, paneData, win); - } - - // Animate in - requestAnimationFrame(() => { - win.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; - win.style.transform = 'scale(1)'; - win.style.opacity = '1'; - }); - } - - /** Rebuild the teammate lookup map from all team configs */ - rebuildTeammateMap() { - this.teammateMap.clear(); - for (const [teamName, team] of this.teams) { - for (const member of team.members) { - if (member.agentType !== 'team-lead') { - // Use name as key prefix for matching subagent descriptions - this.teammateMap.set(member.name, { - name: member.name, - color: member.color || 'blue', - teamName, - agentId: member.agentId, - }); - } - } - } - } - - /** Check if a subagent is a teammate and return its info */ - getTeammateInfo(agent) { - if (!agent?.description) return null; - // Teammate descriptions start with \s]+)/); - if (!match) return null; - const teammateId = match[1]; - // Extract name from teammate_id (format: name@teamName) - const name = teammateId.split('@')[0]; - return this.teammateMap.get(name) || { name, color: 'blue', teamName: 'unknown' }; - } - - /** Get teammate badge HTML for a subagent */ - getTeammateBadgeHtml(agent) { - const info = this.getTeammateInfo(agent); - if (!info) return ''; - return `@${escapeHtml(info.name)}`; - } - - /** Render the team tasks panel */ - renderTeamTasksPanel() { - const panel = document.getElementById('teamTasksPanel'); - if (!panel) return; - - // Find team for active session - let activeTeam = null; - let activeTeamName = null; - if (this.activeSessionId) { - for (const [name, team] of this.teams) { - if (team.leadSessionId === this.activeSessionId) { - activeTeam = team; - activeTeamName = name; - break; - } - } - } - - if (!activeTeam) { - panel.style.display = 'none'; - return; - } - - // Set initial position and make draggable on first show - const wasHidden = panel.style.display === 'none'; - panel.style.display = 'flex'; - - if (wasHidden && !this.teamTasksDragListeners) { - // Position bottom-right - const panelWidth = 360; - const panelHeight = 300; - panel.style.left = `${Math.max(10, window.innerWidth - panelWidth - 20)}px`; - panel.style.top = `${Math.max(10, window.innerHeight - panelHeight - 70)}px`; - // Make draggable - const header = panel.querySelector('.team-tasks-header'); - if (header) { - this.teamTasksDragListeners = this.makeWindowDraggable(panel, header); - } - } - - const tasks = this.teamTasks.get(activeTeamName) || []; - const completed = tasks.filter(t => t.status === 'completed').length; - const total = tasks.length; - const pct = total > 0 ? Math.round((completed / total) * 100) : 0; - - const headerEl = panel.querySelector('.team-tasks-header-text'); - if (headerEl) { - const teammateCount = activeTeam.members.filter(m => m.agentType !== 'team-lead').length; - headerEl.textContent = `Team Tasks (${teammateCount} teammates)`; - } - - const progressEl = panel.querySelector('.team-tasks-progress-fill'); - if (progressEl) { - progressEl.style.width = `${pct}%`; - } - - const progressText = panel.querySelector('.team-tasks-progress-text'); - if (progressText) { - progressText.textContent = `${completed}/${total}`; - } - - const listEl = panel.querySelector('.team-tasks-list'); - if (!listEl) return; - - if (tasks.length === 0) { - listEl.innerHTML = '
No tasks yet
'; - return; - } - - const html = tasks.map(task => { - const statusIcon = task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '◉' : '○'; - const statusClass = task.status.replace('_', '-'); - const ownerBadge = task.owner - ? `${escapeHtml(task.owner)}` - : ''; - return `
- ${statusIcon} - ${escapeHtml(task.subject)} - ${ownerBadge} -
`; - }).join(''); - - listEl.innerHTML = html; - } - - /** Hide team tasks panel and clean up drag listeners */ - hideTeamTasksPanel() { - const panel = document.getElementById('teamTasksPanel'); - if (panel) panel.style.display = 'none'; - 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); - } - if (this.teamTasksDragListeners.handle) { - this.teamTasksDragListeners.handle.removeEventListener('mousedown', this.teamTasksDragListeners.handleMouseDown); - this.teamTasksDragListeners.handle.removeEventListener('touchstart', this.teamTasksDragListeners.handleTouchStart); - } - this.teamTasksDragListeners = null; - } - } - - /** Get teammate color by name */ - getTeammateColor(name) { - const info = this.teammateMap.get(name); - return info?.color || 'blue'; - } - - // ═══════════════════════════════════════════════════════════════ - // Project Insights Panel (Bash Tools with Clickable File Paths) - // ═══════════════════════════════════════════════════════════════ - - /** - * Normalize a file path to its canonical form for comparison. - * - Expands ~ to home directory approximation - * - Resolves relative paths against working directory (case folder) - * - Normalizes . and .. components - */ - normalizeFilePath(path, workingDir) { - if (!path) return ''; - - let normalized = path.trim(); - const homeDir = '/home/' + (window.USER || 'user'); // Approximation - - // Expand ~ to home directory - if (normalized.startsWith('~/')) { - normalized = homeDir + normalized.slice(1); - } else if (normalized === '~') { - normalized = homeDir; - } - - // If not absolute, resolve against working directory (case folder) - if (!normalized.startsWith('/') && workingDir) { - normalized = workingDir + '/' + normalized; - } - - // Normalize path components (resolve . and ..) - const parts = normalized.split('/'); - const stack = []; - - for (const part of parts) { - if (part === '' || part === '.') { - continue; - } else if (part === '..') { - if (stack.length > 1) { - stack.pop(); - } - } else { - stack.push(part); - } - } - - return '/' + stack.join('/'); - } - - /** - * Extract just the filename from a path. - */ - getFilename(path) { - const parts = path.split('/'); - return parts[parts.length - 1] || ''; - } - - /** - * Check if a path is a "shallow root path" - an absolute path with only one - * component after root (e.g., /test.txt, /file.log). - * These are often typos where the user meant a relative path in the case folder. - */ - isShallowRootPath(path) { - if (!path.startsWith('/')) return false; - const parts = path.split('/').filter(p => p !== ''); - return parts.length === 1; - } - - /** - * Check if a path is inside (or is) the working directory (case folder). - */ - isPathInWorkingDir(path, workingDir) { - if (!workingDir) return false; - const normalized = this.normalizeFilePath(path, workingDir); - return normalized.startsWith(workingDir + '/') || normalized === workingDir; - } - - /** - * Smart path equivalence check. - * Two paths are considered equivalent if: - * 1. They normalize to the same path (standard case) - * 2. One is a "shallow root path" (e.g., /test.txt) and the other is the - * same filename inside the case folder - the shallow root path - * is likely a typo and they probably meant the same file. - */ - pathsAreEquivalent(path1, path2, workingDir) { - const norm1 = this.normalizeFilePath(path1, workingDir); - const norm2 = this.normalizeFilePath(path2, workingDir); - - // Standard check: exact normalized match - if (norm1 === norm2) return true; - - // Smart check: shallow root path vs case folder path with same filename - const file1 = this.getFilename(norm1); - const file2 = this.getFilename(norm2); - - if (file1 !== file2) return false; // Different filenames, can't be equivalent - - const shallow1 = this.isShallowRootPath(path1); - const shallow2 = this.isShallowRootPath(path2); - const inWorkDir1 = this.isPathInWorkingDir(norm1, workingDir); - const inWorkDir2 = this.isPathInWorkingDir(norm2, workingDir); - - // If one is shallow root (e.g., /test.txt) and other is in case folder - // with same filename, treat as equivalent (user likely made a typo) - if (shallow1 && inWorkDir2) return true; - if (shallow2 && inWorkDir1) return true; - - return false; - } - - /** - * Pick the "better" of two paths that resolve to the same file. - * Prefers paths inside the case folder, longer/more explicit paths, and absolute paths. - */ - pickBetterPath(path1, path2, workingDir) { - // Prefer paths inside the case folder (working directory) - if (workingDir) { - const inWorkDir1 = this.isPathInWorkingDir(path1, workingDir); - const inWorkDir2 = this.isPathInWorkingDir(path2, workingDir); - if (inWorkDir1 && !inWorkDir2) return path1; - if (inWorkDir2 && !inWorkDir1) return path2; - } - - // Prefer absolute paths - const abs1 = path1.startsWith('/'); - const abs2 = path2.startsWith('/'); - if (abs1 && !abs2) return path1; - if (abs2 && !abs1) return path2; - - // Both absolute or both relative - prefer longer (more explicit) - if (path1.length !== path2.length) { - return path1.length > path2.length ? path1 : path2; - } - - // Prefer paths without ~ - if (!path1.includes('~') && path2.includes('~')) return path1; - if (!path2.includes('~') && path1.includes('~')) return path2; - - return path1; - } - - /** - * Deduplicate file paths across all tools, keeping the "best" version. - * Uses smart equivalence checking: - * - Standard normalization for relative vs absolute paths - * - Detects likely typos (e.g., /file.txt when caseFolder/file.txt exists) - * - Prefers paths inside the case folder (working directory) - * - Prefers longer, more explicit paths - * Returns a Map of normalized path -> best raw path. - */ - deduplicateProjectInsightPaths(tools, workingDir) { - // Collect all paths with their tool IDs - const allPaths = []; - for (const tool of tools) { - for (const rawPath of tool.filePaths) { - allPaths.push({ rawPath, toolId: tool.id }); - } - } - - if (allPaths.length <= 1) { - const pathMap = new Map(); - for (const p of allPaths) { - pathMap.set(this.normalizeFilePath(p.rawPath, workingDir), p); - } - return pathMap; - } - - // Sort paths: prefer paths in case folder first, then by length (longer first) - allPaths.sort((a, b) => { - const aInWorkDir = this.isPathInWorkingDir(a.rawPath, workingDir); - const bInWorkDir = this.isPathInWorkingDir(b.rawPath, workingDir); - if (aInWorkDir && !bInWorkDir) return -1; - if (bInWorkDir && !aInWorkDir) return 1; - return b.rawPath.length - a.rawPath.length; // Longer paths first - }); - - const result = new Map(); // normalized -> { rawPath, toolId } - const seenNormalized = new Set(); - - for (const { rawPath, toolId } of allPaths) { - const normalized = this.normalizeFilePath(rawPath, workingDir); - - // Check if we've already seen an equivalent path - let isDuplicate = false; - for (const [, existing] of result) { - if (this.pathsAreEquivalent(rawPath, existing.rawPath, workingDir)) { - isDuplicate = true; - break; - } - } - - if (!isDuplicate && !seenNormalized.has(normalized)) { - result.set(normalized, { rawPath, toolId }); - seenNormalized.add(normalized); - } - } - - return result; - } - - handleBashToolStart(sessionId, tool) { - let tools = this.projectInsights.get(sessionId) || []; - // Add new tool - tools = tools.filter(t => t.id !== tool.id); - tools.push(tool); - this.projectInsights.set(sessionId, tools); - this.renderProjectInsightsPanel(); - } - - handleBashToolEnd(sessionId, tool) { - const tools = this.projectInsights.get(sessionId) || []; - const existing = tools.find(t => t.id === tool.id); - if (existing) { - existing.status = 'completed'; - } - this.renderProjectInsightsPanel(); - // Remove after a short delay - setTimeout(() => { - const current = this.projectInsights.get(sessionId) || []; - this.projectInsights.set(sessionId, current.filter(t => t.id !== tool.id)); - this.renderProjectInsightsPanel(); - }, 2000); - } - - handleBashToolsUpdate(sessionId, tools) { - this.projectInsights.set(sessionId, tools); - this.renderProjectInsightsPanel(); - } - - renderProjectInsightsPanel() { - const panel = this.$('projectInsightsPanel'); - const list = this.$('projectInsightsList'); - if (!panel || !list) return; - - // Check if panel is enabled in settings - const settings = this.loadAppSettingsFromStorage(); - const showProjectInsights = settings.showProjectInsights ?? false; - if (!showProjectInsights) { - panel.classList.remove('visible'); - this.projectInsightsPanelVisible = false; - return; - } - - // Get tools for active session only - const tools = this.projectInsights.get(this.activeSessionId) || []; - const runningTools = tools.filter(t => t.status === 'running'); - - if (runningTools.length === 0) { - panel.classList.remove('visible'); - this.projectInsightsPanelVisible = false; - return; - } - - panel.classList.add('visible'); - this.projectInsightsPanelVisible = true; - - // Get working directory for path normalization - const session = this.sessions.get(this.activeSessionId); - const workingDir = session?.workingDir || this.currentSessionWorkingDir; - - // Smart deduplication: collect all unique paths across all tools - // Paths that resolve to the same file are deduplicated, keeping the most complete version - const deduplicatedPaths = this.deduplicateProjectInsightPaths(runningTools, workingDir); - - // Build a set of paths to show (only the best version of each unique file) - const pathsToShow = new Set(Array.from(deduplicatedPaths.values()).map(p => p.rawPath)); - - const html = []; - for (const tool of runningTools) { - // Filter this tool's paths to only include those that weren't deduplicated away - const filteredPaths = tool.filePaths.filter(p => pathsToShow.has(p)); - - // Skip tools with no paths to show (all were duplicates of better paths elsewhere) - if (filteredPaths.length === 0) continue; - - const cmdDisplay = tool.command.length > 50 - ? tool.command.substring(0, 50) + '...' - : tool.command; - - html.push(` -
-
- 💻 - ${escapeHtml(cmdDisplay)} - ${tool.status} - ${tool.timeout ? `${escapeHtml(tool.timeout)}` : ''} -
-
- `); - - for (const path of filteredPaths) { - const fileName = path.split('/').pop(); - html.push(` - ${escapeHtml(fileName)} - `); - } - - html.push(` -
-
- `); - } - - list.innerHTML = html.join(''); - } - - closeProjectInsightsPanel() { - const panel = this.$('projectInsightsPanel'); - if (panel) { - panel.classList.remove('visible'); - this.projectInsightsPanelVisible = false; - } - } - - // ═══════════════════════════════════════════════════════════════ - // File Browser Panel - // ═══════════════════════════════════════════════════════════════ - - // File tree data and state - fileBrowserData = null; - fileBrowserExpandedDirs = new Set(); - fileBrowserFilter = ''; - fileBrowserAllExpanded = false; - fileBrowserDragListeners = null; - filePreviewContent = ''; - - async loadFileBrowser(sessionId) { - if (!sessionId) return; - - const treeEl = this.$('fileBrowserTree'); - const statusEl = this.$('fileBrowserStatus'); - if (!treeEl) return; - - // Show loading state - treeEl.innerHTML = '
Loading files...
'; - - try { - const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`); - if (!res.ok) throw new Error('Failed to load files'); - - const result = await res.json(); - if (!result.success) throw new Error(result.error || 'Failed to load files'); - - this.fileBrowserData = result.data; - this.renderFileBrowserTree(); - - // Update status - if (statusEl) { - const { totalFiles, totalDirectories, truncated } = result.data; - statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`; - } - } catch (err) { - console.error('Failed to load file browser:', err); - treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; - } - } - - renderFileBrowserTree() { - const treeEl = this.$('fileBrowserTree'); - if (!treeEl || !this.fileBrowserData) return; - - const { tree } = this.fileBrowserData; - if (!tree || tree.length === 0) { - treeEl.innerHTML = '
No files found
'; - return; - } - - const html = []; - const filter = this.fileBrowserFilter.toLowerCase(); - - const renderNode = (node, depth) => { - const isDir = node.type === 'directory'; - const isExpanded = this.fileBrowserExpandedDirs.has(node.path); - const matchesFilter = !filter || node.name.toLowerCase().includes(filter); - - // For directories, check if any children match - let hasMatchingChildren = false; - if (isDir && filter && node.children) { - hasMatchingChildren = this.hasMatchingChild(node, filter); - } - - const shouldShow = matchesFilter || hasMatchingChildren; - const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : ''; - - const icon = isDir - ? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1') - : this.getFileIcon(node.extension); - - const expandIcon = isDir - ? `\u25B6` - : ''; - - const sizeStr = !isDir && node.size !== undefined - ? `${this.formatFileSize(node.size)}` - : ''; - - const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; - - html.push(` -
- ${expandIcon} - ${icon} - ${escapeHtml(node.name)} - ${sizeStr} -
- `); - - // Render children if directory is expanded - if (isDir && isExpanded && node.children) { - for (const child of node.children) { - renderNode(child, depth + 1); - } - } - }; - - for (const node of tree) { - renderNode(node, 0); - } - - treeEl.innerHTML = html.join(''); - - // Add click handlers - treeEl.querySelectorAll('.file-tree-item').forEach(item => { - item.addEventListener('click', () => { - const path = item.dataset.path; - const type = item.dataset.type; - - if (type === 'directory') { - this.toggleFileBrowserFolder(path); - } else { - this.openFilePreview(path); - } - }); - }); - } - - hasMatchingChild(node, filter) { - if (!node.children) return false; - for (const child of node.children) { - if (child.name.toLowerCase().includes(filter)) return true; - if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true; - } - return false; - } - - toggleFileBrowserFolder(path) { - if (this.fileBrowserExpandedDirs.has(path)) { - this.fileBrowserExpandedDirs.delete(path); - } else { - this.fileBrowserExpandedDirs.add(path); - } - this.renderFileBrowserTree(); - } - - filterFileBrowser(value) { - this.fileBrowserFilter = value; - // Auto-expand all if filtering - if (value) { - this.expandAllDirectories(this.fileBrowserData?.tree || []); - } - this.renderFileBrowserTree(); - } - - expandAllDirectories(nodes) { - for (const node of nodes) { - if (node.type === 'directory') { - this.fileBrowserExpandedDirs.add(node.path); - if (node.children) { - this.expandAllDirectories(node.children); - } - } - } - } - - collapseAllDirectories() { - this.fileBrowserExpandedDirs.clear(); - } - - toggleFileBrowserExpand() { - this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded; - const btn = this.$('fileBrowserExpandBtn'); - - if (this.fileBrowserAllExpanded) { - this.expandAllDirectories(this.fileBrowserData?.tree || []); - if (btn) btn.innerHTML = '\u229F'; // Collapse icon - } else { - this.collapseAllDirectories(); - if (btn) btn.innerHTML = '\u229E'; // Expand icon - } - this.renderFileBrowserTree(); - } - - refreshFileBrowser() { - if (this.activeSessionId) { - this.fileBrowserExpandedDirs.clear(); - this.fileBrowserFilter = ''; - this.fileBrowserAllExpanded = false; - const searchInput = this.$('fileBrowserSearch'); - if (searchInput) searchInput.value = ''; - this.loadFileBrowser(this.activeSessionId); - } - } - - closeFileBrowserPanel() { - const panel = this.$('fileBrowserPanel'); - if (panel) { - panel.classList.remove('visible'); - // Reset position so it reopens at default location - panel.style.left = ''; - panel.style.top = ''; - panel.style.bottom = ''; - panel.style.right = ''; - } - // Clean up drag listeners - if (this.fileBrowserDragListeners) { - const dl = this.fileBrowserDragListeners; - document.removeEventListener('mousemove', dl.move); - document.removeEventListener('mouseup', dl.up); - document.removeEventListener('touchmove', dl.touchMove); - document.removeEventListener('touchend', dl.up); - document.removeEventListener('touchcancel', dl.up); - if (dl.handle) { - dl.handle.removeEventListener('mousedown', dl.handleMouseDown); - dl.handle.removeEventListener('touchstart', dl.handleTouchStart); - if (dl._onFirstDrag) { - dl.handle.removeEventListener('mousedown', dl._onFirstDrag); - dl.handle.removeEventListener('touchstart', dl._onFirstDrag); - } - } - this.fileBrowserDragListeners = null; - } - // Save setting - const settings = this.loadAppSettingsFromStorage(); - settings.showFileBrowser = false; - this.saveAppSettingsToStorage(settings); - } - - async openFilePreview(filePath) { - if (!this.activeSessionId || !filePath) return; - - const overlay = this.$('filePreviewOverlay'); - const titleEl = this.$('filePreviewTitle'); - const bodyEl = this.$('filePreviewBody'); - const footerEl = this.$('filePreviewFooter'); - - if (!overlay || !bodyEl) return; - - // Show overlay with loading state - overlay.classList.add('visible'); - titleEl.textContent = filePath; - bodyEl.innerHTML = '
Loading...
'; - footerEl.textContent = ''; - - try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`); - if (!res.ok) throw new Error('Failed to load file'); - - const result = await res.json(); - if (!result.success) throw new Error(result.error || 'Failed to load file'); - - const data = result.data; - - if (data.type === 'image') { - bodyEl.innerHTML = `${escapeHtml(filePath)}`; - footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; - } else if (data.type === 'video') { - bodyEl.innerHTML = ``; - footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; - } else if (data.type === 'binary') { - bodyEl.innerHTML = `
Binary file (${this.formatFileSize(data.size)})
Cannot preview
`; - footerEl.textContent = data.extension || 'binary'; - } else { - // Text content - this.filePreviewContent = data.content; - bodyEl.innerHTML = `
${escapeHtml(data.content)}
`; - const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : ''; - footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`; - } - } catch (err) { - console.error('Failed to preview file:', err); - bodyEl.innerHTML = `
Error: ${escapeHtml(err.message)}
`; - } - } - - closeFilePreview() { - const overlay = this.$('filePreviewOverlay'); - if (overlay) { - overlay.classList.remove('visible'); - } - this.filePreviewContent = ''; - } - - copyFilePreviewContent() { - if (this.filePreviewContent) { - navigator.clipboard.writeText(this.filePreviewContent).then(() => { - this.showToast('Copied to clipboard', 'success'); - }).catch(() => { - this.showToast('Failed to copy', 'error'); - }); - } - } - - getFileIcon(ext) { - if (!ext) return '\uD83D\uDCC4'; // Default file - - const icons = { - // TypeScript/JavaScript - 'ts': '\uD83D\uDCD8', 'tsx': '\uD83D\uDCD8', 'js': '\uD83D\uDCD2', 'jsx': '\uD83D\uDCD2', - 'mjs': '\uD83D\uDCD2', 'cjs': '\uD83D\uDCD2', - // Python - 'py': '\uD83D\uDC0D', 'pyx': '\uD83D\uDC0D', 'pyw': '\uD83D\uDC0D', - // Rust/Go/C - 'rs': '\uD83E\uDD80', 'go': '\uD83D\uDC39', 'c': '\u2699\uFE0F', 'cpp': '\u2699\uFE0F', - 'h': '\u2699\uFE0F', 'hpp': '\u2699\uFE0F', - // Web - 'html': '\uD83C\uDF10', 'htm': '\uD83C\uDF10', 'css': '\uD83C\uDFA8', 'scss': '\uD83C\uDFA8', - 'sass': '\uD83C\uDFA8', 'less': '\uD83C\uDFA8', - // Data - 'json': '\uD83D\uDCCB', 'yaml': '\uD83D\uDCCB', 'yml': '\uD83D\uDCCB', 'xml': '\uD83D\uDCCB', - 'toml': '\uD83D\uDCCB', 'csv': '\uD83D\uDCCB', - // Docs - 'md': '\uD83D\uDCDD', 'markdown': '\uD83D\uDCDD', 'txt': '\uD83D\uDCDD', 'rst': '\uD83D\uDCDD', - // Images - 'png': '\uD83D\uDDBC\uFE0F', 'jpg': '\uD83D\uDDBC\uFE0F', 'jpeg': '\uD83D\uDDBC\uFE0F', - 'gif': '\uD83D\uDDBC\uFE0F', 'svg': '\uD83D\uDDBC\uFE0F', 'webp': '\uD83D\uDDBC\uFE0F', - 'ico': '\uD83D\uDDBC\uFE0F', 'bmp': '\uD83D\uDDBC\uFE0F', - // Video/Audio - 'mp4': '\uD83C\uDFAC', 'webm': '\uD83C\uDFAC', 'mov': '\uD83C\uDFAC', - 'mp3': '\uD83C\uDFB5', 'wav': '\uD83C\uDFB5', 'ogg': '\uD83C\uDFB5', - // Config/Shell - 'sh': '\uD83D\uDCBB', 'bash': '\uD83D\uDCBB', 'zsh': '\uD83D\uDCBB', - 'env': '\uD83D\uDD10', 'gitignore': '\uD83D\uDEAB', 'dockerfile': '\uD83D\uDC33', - // Lock files - 'lock': '\uD83D\uDD12', - }; - - return icons[ext.toLowerCase()] || '\uD83D\uDCC4'; - } - - formatFileSize(bytes) { - if (bytes === undefined || bytes === null) return ''; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - } - - // ═══════════════════════════════════════════════════════════════ - // Log Viewer Windows (Floating File Streamers) - // ═══════════════════════════════════════════════════════════════ - - openLogViewerWindow(filePath, sessionId) { - sessionId = sessionId || this.activeSessionId; - if (!sessionId) return; - - // Create unique window ID - const windowId = `${sessionId}-${filePath.replace(/[^a-zA-Z0-9]/g, '_')}`; - - // If window already exists, focus it - if (this.logViewerWindows.has(windowId)) { - const existing = this.logViewerWindows.get(windowId); - existing.element.style.zIndex = ++this.logViewerWindowZIndex; - return; - } - - // Calculate position (cascade from top-left) - const windowCount = this.logViewerWindows.size; - const offsetX = 100 + (windowCount % 5) * 30; - const offsetY = 100 + (windowCount % 5) * 30; - - // Get filename for title - const fileName = filePath.split('/').pop(); - - // Create window element - const win = document.createElement('div'); - win.className = 'log-viewer-window'; - win.id = `log-viewer-window-${windowId}`; - win.style.left = `${offsetX}px`; - win.style.top = `${offsetY}px`; - win.style.zIndex = ++this.logViewerWindowZIndex; - - win.innerHTML = ` -
-
- 📄 - ${escapeHtml(fileName)} - streaming -
-
- -
-
-
-
Connecting to ${escapeHtml(filePath)}...
-
- `; - - document.body.appendChild(win); - - // Make draggable (returns listener refs for cleanup) - const dragListeners = this.makeWindowDraggable(win, win.querySelector('.log-viewer-window-header')); - - // Connect to SSE stream - const eventSource = new EventSource( - `/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50` - ); - - eventSource.onmessage = (e) => { - const data = JSON.parse(e.data); - const body = document.getElementById(`log-viewer-body-${windowId}`); - if (!body) return; - - switch (data.type) { - case 'connected': - body.innerHTML = ''; - break; - case 'data': - // Append data, auto-scroll - const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 10; - const content = escapeHtml(data.content); - body.innerHTML += content; - if (wasAtBottom) { - body.scrollTop = body.scrollHeight; - } - // Trim if too large - if (body.innerHTML.length > 500000) { - body.innerHTML = body.innerHTML.slice(-400000); - } - break; - case 'end': - this.updateLogViewerStatus(windowId, 'disconnected', 'ended'); - break; - case 'error': - body.innerHTML += `
${escapeHtml(data.error)}
`; - this.updateLogViewerStatus(windowId, 'error', 'error'); - break; - } - }; - - eventSource.onerror = () => { - this.updateLogViewerStatus(windowId, 'disconnected', 'connection error'); - }; - - // Store reference (including drag listeners for cleanup) - this.logViewerWindows.set(windowId, { - element: win, - eventSource, - filePath, - sessionId, - dragListeners, // Store for cleanup to prevent memory leaks - }); - } - - updateLogViewerStatus(windowId, statusClass, statusText) { - const statusEl = document.querySelector(`#log-viewer-window-${windowId} .status`); - if (statusEl) { - statusEl.className = `status ${statusClass}`; - statusEl.textContent = statusText; - } - } - - closeLogViewerWindow(windowId) { - const windowData = this.logViewerWindows.get(windowId); - if (!windowData) return; - - // Close SSE connection - if (windowData.eventSource) { - windowData.eventSource.close(); - } - - // Clean up drag event listeners (both document-level and handle-level) - if (windowData.dragListeners) { - document.removeEventListener('mousemove', windowData.dragListeners.move); - document.removeEventListener('mouseup', windowData.dragListeners.up); - if (windowData.dragListeners.handle) { - windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown); - windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart); - } - } - - // Remove element - windowData.element.remove(); - - // Remove from map - this.logViewerWindows.delete(windowId); - } - - // Close all log viewer windows for a session - closeSessionLogViewerWindows(sessionId) { - const toClose = []; - for (const [windowId, data] of this.logViewerWindows) { - if (data.sessionId === sessionId) { - toClose.push(windowId); - } - } - for (const windowId of toClose) { - this.closeLogViewerWindow(windowId); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Image Popup Windows (Auto-popup for Screenshots) - // ═══════════════════════════════════════════════════════════════ - - /** - * Open a popup window to display a detected image. - * Called automatically when image:detected SSE event is received. - */ - openImagePopup(imageEvent) { - const { sessionId, filePath, relativePath, fileName, timestamp, size } = imageEvent; - - // Create unique window ID - const imageId = `${sessionId}-${timestamp}`; - - // If window already exists for this image, focus it - if (this.imagePopups.has(imageId)) { - const existing = this.imagePopups.get(imageId); - existing.element.style.zIndex = ++this.imagePopupZIndex; - return; - } - - // Cap open popups at 20 — close oldest when at limit - const MAX_IMAGE_POPUPS = 20; - if (this.imagePopups.size >= MAX_IMAGE_POPUPS) { - // Map iteration order is insertion order, so first key is oldest - const oldestId = this.imagePopups.keys().next().value; - if (oldestId) this.closeImagePopup(oldestId); - } - - // Calculate position (cascade from center, with offset for multiple popups) - const windowCount = this.imagePopups.size; - const centerX = (window.innerWidth - 600) / 2; - const centerY = (window.innerHeight - 500) / 2; - const offsetX = centerX + (windowCount % 5) * 30; - const offsetY = centerY + (windowCount % 5) * 30; - - // Get session name for display - const session = this.sessions.get(sessionId); - const sessionName = session?.name || sessionId.substring(0, 8); - - // Format file size - const sizeKB = (size / 1024).toFixed(1); - - // Build image URL using the existing file-raw endpoint - // Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images - const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`; - - // Create window element - const win = document.createElement('div'); - win.className = 'image-popup-window'; - win.id = `image-popup-${imageId}`; - win.style.left = `${offsetX}px`; - win.style.top = `${offsetY}px`; - win.style.zIndex = ++this.imagePopupZIndex; - - win.innerHTML = ` -
-
- 🖼️ - ${escapeHtml(fileName)} - ${escapeHtml(sessionName)} - ${sizeKB} KB -
-
- - -
-
-
- ${escapeHtml(fileName)} -
- `; - - document.body.appendChild(win); - - // Make draggable - const dragListeners = this.makeWindowDraggable(win, win.querySelector('.image-popup-header')); - - // Focus on click - win.addEventListener('mousedown', () => { - win.style.zIndex = ++this.imagePopupZIndex; - }); - - // Store reference - this.imagePopups.set(imageId, { - element: win, - sessionId, - filePath, - dragListeners, - }); - } - - /** - * Close an image popup window. - */ - closeImagePopup(imageId) { - const popupData = this.imagePopups.get(imageId); - if (!popupData) return; - - // Clean up drag event listeners (both document-level and handle-level) - 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); - } - if (popupData.dragListeners.handle) { - popupData.dragListeners.handle.removeEventListener('mousedown', popupData.dragListeners.handleMouseDown); - popupData.dragListeners.handle.removeEventListener('touchstart', popupData.dragListeners.handleTouchStart); - } - } - - // Remove element - popupData.element.remove(); - - // Remove from map - this.imagePopups.delete(imageId); - } - - /** - * Open image in a new browser tab. - */ - openImageInNewTab(url) { - window.open(url, '_blank'); - } - - /** - * Close all image popups for a session. - */ - closeSessionImagePopups(sessionId) { - const toClose = []; - for (const [imageId, data] of this.imagePopups) { - if (data.sessionId === sessionId) { - toClose.push(imageId); - } - } - for (const imageId of toClose) { - this.closeImagePopup(imageId); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Mux Sessions (in Monitor Panel) - // ═══════════════════════════════════════════════════════════════ - - async loadMuxSessions() { - try { - const res = await fetch('/api/mux-sessions'); - const data = await res.json(); - this.muxSessions = data.sessions || []; - this.renderMuxSessions(); - } catch (err) { - console.error('Failed to load mux sessions:', err); - } - } - - killAllMuxSessions() { - const count = this.muxSessions?.length || 0; - if (count === 0) { - alert('No sessions to kill'); - return; - } - - // Show the kill all modal - document.getElementById('killAllCount').textContent = count; - const modal = document.getElementById('killAllModal'); - modal.classList.add('active'); - - // Activate focus trap - this.activeFocusTrap = new FocusTrap(modal); - this.activeFocusTrap.activate(); - } - - closeKillAllModal() { - document.getElementById('killAllModal').classList.remove('active'); - - // Deactivate focus trap and restore focus - if (this.activeFocusTrap) { - this.activeFocusTrap.deactivate(); - this.activeFocusTrap = null; - } - } - - async confirmKillAll(killMux) { - this.closeKillAllModal(); - - try { - if (killMux) { - // Kill everything including tmux sessions - const res = await fetch('/api/sessions', { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - this.sessions.clear(); - this.muxSessions = []; - this.activeSessionId = null; - try { localStorage.removeItem('codeman-active-session'); } catch {} - this.renderSessionTabs(); - this.renderMuxSessions(); - this.terminal.clear(); - this.terminal.reset(); - this.toast('All sessions and tmux killed', 'success'); - } - } else { - // Just remove tabs, keep mux sessions running - this.sessions.clear(); - this.activeSessionId = null; - try { localStorage.removeItem('codeman-active-session'); } catch {} - this.renderSessionTabs(); - this.terminal.clear(); - this.terminal.reset(); - this.toast('All tabs removed, tmux still running', 'info'); - } - } catch (err) { - console.error('Failed to kill sessions:', err); - this.toast('Failed to kill sessions: ' + err.message, 'error'); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Case Settings - // ═══════════════════════════════════════════════════════════════ - - toggleCaseSettings() { - const popover = document.getElementById('caseSettingsPopover'); - if (popover.classList.contains('hidden')) { - // Load settings for current case - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const settings = this.getCaseSettings(caseName); - document.getElementById('caseAgentTeams').checked = settings.agentTeams; - popover.classList.remove('hidden'); - - // Close on outside click (one-shot listener) - const closeHandler = (e) => { - if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) { - popover.classList.add('hidden'); - document.removeEventListener('click', closeHandler); - } - }; - // Defer to avoid catching the current click - setTimeout(() => document.addEventListener('click', closeHandler), 0); - } else { - popover.classList.add('hidden'); - } - } - - getCaseSettings(caseName) { - try { - const stored = localStorage.getItem('caseSettings_' + caseName); - if (stored) return JSON.parse(stored); - } catch { /* ignore */ } - return { agentTeams: false }; - } - - saveCaseSettings(caseName, settings) { - localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings)); - } - - onCaseSettingChanged() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const settings = this.getCaseSettings(caseName); - settings.agentTeams = document.getElementById('caseAgentTeams').checked; - this.saveCaseSettings(caseName, settings); - // Sync mobile checkbox - const mobileCheckbox = document.getElementById('caseAgentTeamsMobile'); - if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams; - } - - toggleCaseSettingsMobile() { - const popover = document.getElementById('caseSettingsPopoverMobile'); - if (popover.classList.contains('hidden')) { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const settings = this.getCaseSettings(caseName); - document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams; - popover.classList.remove('hidden'); - - const closeHandler = (e) => { - if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) { - popover.classList.add('hidden'); - document.removeEventListener('click', closeHandler); - } - }; - setTimeout(() => document.addEventListener('click', closeHandler), 0); - } else { - popover.classList.add('hidden'); - } - } - - onCaseSettingChangedMobile() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const settings = this.getCaseSettings(caseName); - settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked; - this.saveCaseSettings(caseName, settings); - // Sync desktop checkbox - const desktopCheckbox = document.getElementById('caseAgentTeams'); - if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams; - } - - // ═══════════════════════════════════════════════════════════════ - // Create Case Modal - // ═══════════════════════════════════════════════════════════════ - - showCreateCaseModal() { - document.getElementById('newCaseName').value = ''; - document.getElementById('newCaseDescription').value = ''; - document.getElementById('linkCaseName').value = ''; - document.getElementById('linkCasePath').value = ''; - // Reset to first tab - this.caseModalTab = 'case-create'; - this.switchCaseModalTab('case-create'); - // Wire up tab buttons - const modal = document.getElementById('createCaseModal'); - modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { - btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab); - }); - // Scroll-into-view on focus for mobile keyboard visibility - modal.querySelectorAll('input[type="text"]').forEach(input => { - if (!input._mobileScrollWired) { - input._mobileScrollWired = true; - input.addEventListener('focus', () => { - if (window.innerWidth <= 430) { - setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300); - } - }); - } - }); - modal.classList.add('active'); - document.getElementById('newCaseName').focus(); - } - - switchCaseModalTab(tabName) { - this.caseModalTab = tabName; - const modal = document.getElementById('createCaseModal'); - // Toggle active class on tab buttons - modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.tab === tabName); - }); - // Toggle hidden class on tab content - modal.querySelectorAll('.modal-tab-content').forEach(content => { - content.classList.toggle('hidden', content.id !== tabName); - }); - // Update submit button text - const submitBtn = document.getElementById('caseModalSubmit'); - submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link'; - // Focus appropriate input - if (tabName === 'case-create') { - document.getElementById('newCaseName').focus(); - } else { - document.getElementById('linkCaseName').focus(); - } - } - - closeCreateCaseModal() { - document.getElementById('createCaseModal').classList.remove('active'); - } - - async submitCaseModal() { - const btn = document.getElementById('caseModalSubmit'); - const originalText = btn.textContent; - btn.classList.add('loading'); - btn.textContent = this.caseModalTab === 'case-create' ? 'Creating...' : 'Linking...'; - try { - if (this.caseModalTab === 'case-create') { - await this.createCase(); - } else { - await this.linkCase(); - } - } finally { - btn.classList.remove('loading'); - btn.textContent = originalText; - } - } - - async createCase() { - const name = document.getElementById('newCaseName').value.trim(); - const description = document.getElementById('newCaseDescription').value.trim(); - - if (!name) { - this.showToast('Please enter a case name', 'error'); - return; - } - - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error'); - return; - } - - try { - const res = await fetch('/api/cases', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description }) - }); - - const data = await res.json(); - if (data.success) { - this.closeCreateCaseModal(); - this.showToast(`Case "${name}" created`, 'success'); - // Reload cases and select the new one - await this.loadQuickStartCases(name); - // Save as last used case - await this.saveLastUsedCase(name); - } else { - this.showToast(data.error || 'Failed to create case', 'error'); - } - } catch (err) { - console.error('Failed to create case:', err); - this.showToast('Failed to create case: ' + err.message, 'error'); - } - } - - async linkCase() { - const name = document.getElementById('linkCaseName').value.trim(); - const path = document.getElementById('linkCasePath').value.trim(); - - if (!name) { - this.showToast('Please enter a case name', 'error'); - return; - } - - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error'); - return; - } - - if (!path) { - this.showToast('Please enter a folder path', 'error'); - return; - } - - try { - const res = await fetch('/api/cases/link', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, path }) - }); - - const data = await res.json(); - if (data.success) { - this.closeCreateCaseModal(); - this.showToast(`Case "${name}" linked to ${path}`, 'success'); - // Reload cases and select the new one - await this.loadQuickStartCases(name); - // Save as last used case - await this.saveLastUsedCase(name); - } else { - this.showToast(data.error || 'Failed to link case', 'error'); - } - } catch (err) { - console.error('Failed to link case:', err); - this.showToast('Failed to link case: ' + err.message, 'error'); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Mobile Case Picker - // ═══════════════════════════════════════════════════════════════ - - showMobileCasePicker() { - const modal = document.getElementById('mobileCasePickerModal'); - const listContainer = document.getElementById('mobileCaseList'); - const select = document.getElementById('quickStartCase'); - const currentCase = select.value; - - // Build case list HTML - let html = ''; - const cases = this.cases || []; - - // Add testcase if not in list - const hasTestcase = cases.some(c => c.name === 'testcase'); - const allCases = hasTestcase ? cases : [{ name: 'testcase' }, ...cases]; - - for (const c of allCases) { - const isSelected = c.name === currentCase; - html += ` - - `; - } - - listContainer.innerHTML = html; - modal.classList.add('active'); - } - - closeMobileCasePicker() { - document.getElementById('mobileCasePickerModal').classList.remove('active'); - } - - selectMobileCase(caseName) { - // Update the desktop select (source of truth) - const select = document.getElementById('quickStartCase'); - select.value = caseName; - - // Update mobile button label - this.updateMobileCaseLabel(caseName); - - // Update directory display - this.updateDirDisplayForCase(caseName); - - // Save as last used - this.saveLastUsedCase(caseName); - - // Close the picker - this.closeMobileCasePicker(); - - this.showToast(`Selected: ${caseName}`, 'success'); - } - - updateMobileCaseLabel(caseName) { - const label = document.getElementById('mobileCaseName'); - if (label) { - // Let CSS handle truncation via text-overflow: ellipsis - label.textContent = caseName; - } - } - - showCreateCaseFromMobile() { - // Close mobile picker first - this.closeMobileCasePicker(); - // Open the create case modal with slide-up animation - this.showCreateCaseModal(); - const modal = document.getElementById('createCaseModal'); - modal.classList.add('from-mobile'); - // Remove animation class after it plays - setTimeout(() => modal.classList.remove('from-mobile'), 300); - } - - renderMuxSessions() { - // Debounce renders at 100ms to prevent excessive DOM updates - if (this.renderMuxSessionsTimeout) { - clearTimeout(this.renderMuxSessionsTimeout); - } - this.renderMuxSessionsTimeout = setTimeout(() => { - this._renderMuxSessionsImmediate(); - }, 100); - } - - _renderMuxSessionsImmediate() { - const body = document.getElementById('muxSessionsBody'); - - if (!this.muxSessions || this.muxSessions.length === 0) { - body.innerHTML = '
No mux sessions
'; - return; - } - - let html = ''; - for (const muxSession of this.muxSessions) { - const stats = muxSession.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 }; - - // Look up rich session data by sessionId - const session = this.sessions.get(muxSession.sessionId); - const status = session ? session.status : 'unknown'; - const isWorking = session ? session.isWorking : false; - - // Status badge - let statusLabel, statusClass; - if (status === 'idle' && !isWorking) { - statusLabel = 'IDLE'; - statusClass = 'status-idle'; - } else if (status === 'busy' || isWorking) { - statusLabel = 'WORKING'; - statusClass = 'status-working'; - } else if (status === 'stopped') { - statusLabel = 'STOPPED'; - statusClass = 'status-stopped'; - } else { - statusLabel = status.toUpperCase(); - statusClass = ''; - } - - // Token and cost info - const tokens = session && session.tokens ? session.tokens : null; - const totalCost = session ? session.totalCost : 0; - const model = session ? (session.cliModel || '') : ''; - const modelShort = model.includes('opus') ? 'opus' : model.includes('sonnet') ? 'sonnet' : model.includes('haiku') ? 'haiku' : ''; - - // Ralph/Todo progress - const todoStats = session ? session.ralphTodoStats : null; - let todoHtml = ''; - if (todoStats && todoStats.total > 0) { - const pct = Math.round((todoStats.completed / todoStats.total) * 100); - todoHtml = `${todoStats.completed}/${todoStats.total} (${pct}%)`; - } - - // Format tokens - let tokenHtml = ''; - if (tokens && tokens.total > 0) { - const totalK = (tokens.total / 1000).toFixed(1); - tokenHtml = `${totalK}k tok`; - } - - // Format cost - let costHtml = ''; - if (totalCost > 0) { - costHtml = `$${totalCost.toFixed(2)}`; - } - - // Model badge - let modelHtml = ''; - if (modelShort) { - modelHtml = `${modelShort}`; - } - - html += ` -
- ${statusLabel} -
-
${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}
-
- ${tokenHtml} - ${costHtml} - ${todoHtml} - ${stats.memoryMB}MB - ${stats.cpuPercent}% -
-
-
- -
-
- `; - } - - body.innerHTML = html; - } - - renderMonitorSubagents() { - const body = document.getElementById('monitorSubagentsBody'); - const stats = document.getElementById('monitorSubagentStats'); - if (!body) return; - - const subagents = Array.from(this.subagents.values()); - const activeCount = subagents.filter(s => s.status === 'active' || s.status === 'idle').length; - - if (stats) { - stats.textContent = `${subagents.length} tracked` + (activeCount > 0 ? `, ${activeCount} active` : ''); - } - - if (subagents.length === 0) { - body.innerHTML = '
No background agents
'; - return; - } - - let html = ''; - for (const agent of subagents) { - const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed'; - const modelBadge = agent.modelShort ? `${agent.modelShort}` : ''; - const desc = agent.description ? escapeHtml(agent.description.substring(0, 40)) : agent.agentId; - - html += ` -
- ${agent.status} -
-
${modelBadge} ${desc}
-
- ID: ${agent.agentId} - ${agent.toolCallCount || 0} tools -
-
-
- ${agent.status !== 'completed' ? `` : ''} -
-
- `; - } - - body.innerHTML = html; - } - - async killMuxSession(sessionId) { - if (!confirm('Kill this mux session?')) return; - - try { - // Use closeSession to properly clean up both the session tab and tmux process - // (closeSession handles its own toast messaging) - await this.closeSession(sessionId, true); - } catch (err) { - // Fallback: kill mux directly if session cleanup fails - try { await fetch(`/api/mux-sessions/${sessionId}`, { method: 'DELETE' }); } catch (_ignored) {} - this.showToast('Tmux session killed', 'success'); - } - this.muxSessions = this.muxSessions.filter(s => s.sessionId !== sessionId); - this.renderMuxSessions(); - } - - async reconcileMuxSessions() { - try { - const res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' }); - const data = await res.json(); - - if (data.dead && data.dead.length > 0) { - this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning'); - await this.loadMuxSessions(); - } else { - this.showToast('All mux sessions are alive', 'success'); - } - } catch (err) { - this.showToast('Failed to reconcile mux sessions', 'error'); - } - } - - // ═══════════════════════════════════════════════════════════════ - // Plan Wizard Agents in Monitor - // ═══════════════════════════════════════════════════════════════ - - renderMonitorPlanAgents() { - const section = document.getElementById('monitorPlanAgentsSection'); - const body = document.getElementById('monitorPlanAgentsBody'); - const stats = document.getElementById('monitorPlanAgentStats'); - if (!section || !body) return; - - const planAgents = Array.from(this.planSubagents?.values() || []); - const hasActiveOrchestrator = !!this.activePlanOrchestratorId; - - // Show section only if there are plan agents or active orchestrator - if (planAgents.length === 0 && !hasActiveOrchestrator) { - section.style.display = 'none'; - return; - } - - section.style.display = ''; - - const activeCount = planAgents.filter(a => a.status === 'running').length; - const completedCount = planAgents.filter(a => a.status === 'completed' || a.status === 'failed').length; - - if (stats) { - if (hasActiveOrchestrator) { - stats.textContent = `${activeCount} running, ${completedCount} done`; - } else { - stats.textContent = `${planAgents.length} total`; - } - } - - if (planAgents.length === 0) { - body.innerHTML = `
${hasActiveOrchestrator ? 'Plan generation starting...' : 'No plan agents'}
`; - return; - } - - let html = ''; - for (const agent of planAgents) { - const statusClass = agent.status === 'running' ? 'active' : agent.status === 'completed' ? 'completed' : 'error'; - const agentLabel = agent.agentType || agent.agentId; - const modelBadge = agent.model ? `opus` : ''; - const detail = agent.detail ? escapeHtml(agent.detail.substring(0, 50)) : ''; - const duration = agent.durationMs ? `${(agent.durationMs / 1000).toFixed(1)}s` : ''; - const itemCount = agent.itemCount ? `${agent.itemCount} items` : ''; - - html += ` -
- ${agent.status || 'pending'} -
-
${modelBadge} ${escapeHtml(agentLabel)}
-
- ${detail ? `${detail}` : ''} - ${itemCount ? `${itemCount}` : ''} - ${duration ? `${duration}` : ''} -
-
-
- `; - } - - body.innerHTML = html; - } - - async cancelPlanFromMonitor() { - if (!this.activePlanOrchestratorId && this.planSubagents?.size === 0) { - this.showToast('No active plan generation', 'info'); - return; - } - - if (!confirm('Cancel plan generation and close all plan agent windows?')) return; - - // Cancel the plan generation (reuse existing method) - await this.cancelPlanGeneration(); - - // Also force close the wizard if it's open - const wizardModal = document.getElementById('ralphWizardModal'); - if (wizardModal?.classList.contains('active')) { - this.closeRalphWizard(); - } - - // Update monitor display - this.renderMonitorPlanAgents(); - this.showToast('Plan generation cancelled', 'success'); - } - - // ═══════════════════════════════════════════════════════════════ - // Toast - // ═══════════════════════════════════════════════════════════════ - - // Cached toast container for performance - _toastContainer = null; - - toggleNotifications() { - this.notificationManager?.toggleDrawer(); - } - - // Alias for showToast - toast(message, type = 'info') { - return this.showToast(message, type); - } - - showToast(message, type = 'info', opts = {}) { - const { duration = 3000, action } = opts; - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - - const msgSpan = document.createElement('span'); - msgSpan.textContent = message; - toast.appendChild(msgSpan); - - if (action) { - const btn = document.createElement('button'); - btn.textContent = action.label; - btn.style.cssText = 'margin-left:12px;padding:2px 10px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);border-radius:3px;color:inherit;cursor:pointer;font-size:12px'; - btn.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); }; - toast.appendChild(btn); - } - - // Cache toast container reference - if (!this._toastContainer) { - this._toastContainer = document.querySelector('.toast-container'); - if (!this._toastContainer) { - this._toastContainer = document.createElement('div'); - this._toastContainer.className = 'toast-container'; - document.body.appendChild(this._toastContainer); - } - } - this._toastContainer.appendChild(toast); - - requestAnimationFrame(() => toast.classList.add('show')); - - setTimeout(() => { - toast.classList.remove('show'); - setTimeout(() => toast.remove(), 200); - }, duration); - } - - // ═══════════════════════════════════════════════════════════════ - // System Stats - // ═══════════════════════════════════════════════════════════════ - - startSystemStatsPolling() { - // Clear any existing interval to prevent duplicates - this.stopSystemStatsPolling(); - - // Initial fetch - this.fetchSystemStats(); - - // Poll every 2 seconds - this.systemStatsInterval = setInterval(() => { - this.fetchSystemStats(); - }, 2000); - } - - stopSystemStatsPolling() { - if (this.systemStatsInterval) { - clearInterval(this.systemStatsInterval); - this.systemStatsInterval = null; - } - } - - async fetchSystemStats() { - // Skip polling when system stats display is hidden - const statsEl = document.getElementById('headerSystemStats'); - if (!statsEl || statsEl.style.display === 'none') return; - - try { - const res = await fetch('/api/system/stats'); - const stats = await res.json(); - this.updateSystemStatsDisplay(stats); - } catch (err) { - // Silently fail - system stats are not critical - } - } - - updateSystemStatsDisplay(stats) { - const cpuEl = this.$('statCpu'); - const cpuBar = this.$('statCpuBar'); - const memEl = this.$('statMem'); - const memBar = this.$('statMemBar'); - - if (cpuEl && cpuBar) { - cpuEl.textContent = `${stats.cpu}%`; - cpuBar.style.width = `${Math.min(100, stats.cpu)}%`; - - // Color classes based on usage - cpuBar.classList.remove('medium', 'high'); - cpuEl.classList.remove('high'); - if (stats.cpu > 80) { - cpuBar.classList.add('high'); - cpuEl.classList.add('high'); - } else if (stats.cpu > 50) { - cpuBar.classList.add('medium'); - } - } - - if (memEl && memBar) { - const memGB = (stats.memory.usedMB / 1024).toFixed(1); - memEl.textContent = `${memGB}G`; - memBar.style.width = `${Math.min(100, stats.memory.percent)}%`; - - // Color classes based on usage - memBar.classList.remove('medium', 'high'); - memEl.classList.remove('high'); - if (stats.memory.percent > 80) { - memBar.classList.add('high'); - memEl.classList.add('high'); - } else if (stats.memory.percent > 50) { - memBar.classList.add('medium'); - } - } - } - } // ═══════════════════════════════════════════════════════════════ @@ -12663,9 +2613,12 @@ try { } } catch {} -// Initialize -const app = new CodemanApp(); - -// Expose for debugging/testing -window.app = app; +// Initialize — use DOMContentLoaded to ensure all defer'd mixin modules +// (terminal-ui.js, settings-ui.js, etc.) have executed their Object.assign +// onto CodemanApp.prototype before we instantiate. +let app; +document.addEventListener('DOMContentLoaded', () => { + app = new CodemanApp(); + window.app = app; +}); window.MobileDetection = MobileDetection; diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 44e6c9a0..28388fb5 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -14,8 +14,10 @@ * @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets * * @dependency None (first in load order) - * @loadorder 1 of 9 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js - * → keyboard-accessory.js → app.js → ralph-wizard.js → api-client.js → subagent-windows.js + * @loadorder 1 of 15 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js + * → keyboard-accessory.js → input-cjk.js → app.js → terminal-ui.js → respawn-ui.js + * → ralph-panel.js → settings-ui.js → panels-ui.js → session-ui.js → ralph-wizard.js + * → api-client.js → subagent-windows.js */ // Codeman — Shared constants and utility functions for frontend modules diff --git a/src/web/public/index.html b/src/web/public/index.html index 11180eb3..81151a12 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1701,6 +1701,12 @@ + + + + + + diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index 0cba507e..ca7782b5 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -9,7 +9,7 @@ * * @dependency index.html (#cjkInput textarea) * @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData - * @loadorder 5.5 of 10 — loaded after keyboard-accessory.js, before app.js + * @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js */ // eslint-disable-next-line no-unused-vars diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 325df15e..77df2a2b 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -18,7 +18,7 @@ * * @dependency mobile-handlers.js (MobileDetection.isTouchDevice) * @dependency app.js (uses global `app` for sendInput, activeSessionId, terminal) - * @loadorder 5 of 9 — loaded after notification-manager.js, before app.js + * @loadorder 5 of 15 — loaded after notification-manager.js, before app.js */ // Codeman — Keyboard accessory bar and focus trap for modals diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index f46a7c56..d9fac4cb 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -19,7 +19,7 @@ * @globals {object} SwipeHandler * * @dependency keyboard-accessory.js (KeyboardAccessoryBar reference in KeyboardHandler.onKeyboardShow, soft — guarded with typeof check) - * @loadorder 2 of 9 — loaded after constants.js, before voice-input.js + * @loadorder 2 of 15 — loaded after constants.js, before voice-input.js */ // Codeman — Mobile detection, keyboard handling, and swipe navigation diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js index 9d3c3319..9e661290 100644 --- a/src/web/public/notification-manager.js +++ b/src/web/public/notification-manager.js @@ -22,7 +22,7 @@ * * @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants) * @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults) - * @loadorder 4 of 9 — loaded after voice-input.js, before keyboard-accessory.js + * @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js */ // Codeman — Multi-layer notification system diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js new file mode 100644 index 00000000..bd0fd0c6 --- /dev/null +++ b/src/web/public/panels-ui.js @@ -0,0 +1,3284 @@ +/** + * @fileoverview Subagent panel (discovery, detail view, kill), subagent parent tracking, + * agent teams (tasks panel, teammate badges, terminals), project insights (bash tool tracking), + * file browser (directory tree, preview), log viewer (floating file streamers), + * image popups (auto-popup for screenshots), mux sessions, monitor panel, + * token statistics, toast notifications, and system stats. + * Includes 19 SSE handlers for tasks, mux, bash tools, subagents, and images. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.sessions) + * @dependency constants.js (escapeHtml, ZINDEX_* constants) + * @dependency subagent-windows.js (openSubagentWindow, closeSubagentWindow) + * @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js + */ + +Object.assign(CodemanApp.prototype, { + // Tasks + _onTaskCreated(data) { + this.renderSessionTabs(); + if (data.sessionId === this.activeSessionId) { + this.renderTaskPanel(); + } + }, + + _onTaskCompleted(data) { + this.renderSessionTabs(); + if (data.sessionId === this.activeSessionId) { + this.renderTaskPanel(); + } + }, + + _onTaskFailed(data) { + this.renderSessionTabs(); + if (data.sessionId === this.activeSessionId) { + this.renderTaskPanel(); + } + }, + + _onTaskUpdated(data) { + if (data.sessionId === this.activeSessionId) { + this.renderTaskPanel(); + } + }, + + // Mux (tmux) + _onMuxCreated(data) { + this.muxSessions.push(data); + this.renderMuxSessions(); + }, + + _onMuxKilled(data) { + this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId); + this.renderMuxSessions(); + }, + + _onMuxDied(data) { + this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId); + this.renderMuxSessions(); + this.showToast('Mux session died: ' + this.getShortId(data.sessionId), 'warning'); + }, + + _onMuxStatsUpdated(data) { + this.muxSessions = data; + if (document.getElementById('monitorPanel').classList.contains('open')) { + this.renderMuxSessions(); + } + }, + + + // Bash tools + _onBashToolStart(data) { + this.handleBashToolStart(data.sessionId, data.tool); + }, + + _onBashToolEnd(data) { + this.handleBashToolEnd(data.sessionId, data.tool); + }, + + _onBashToolsUpdate(data) { + this.handleBashToolsUpdate(data.sessionId, data.tools); + }, + + + // Subagents (Claude Code background agents) + _onSubagentDiscovered(data) { + // Clear all old data for this agentId (in case of ID reuse) + this.subagents.set(data.agentId, data); + this.subagentActivity.set(data.agentId, []); + this.subagentToolResults.delete(data.agentId); + // Close any existing window for this agentId (will be reopened fresh) + if (this.subagentWindows.has(data.agentId)) { + this.forceCloseSubagentWindow(data.agentId); + } + this.renderSubagentPanel(); + + // Find which Codeman session owns this subagent (direct claudeSessionId match only) + this.findParentSessionForSubagent(data.agentId); + + // Auto-open window for new active agents — but ONLY if they belong to a Codeman session tab. + // Agents from external Claude sessions (not managed by Codeman) should not pop up. + if (data.status === 'active') { + const agentForCheck = this.subagents.get(data.agentId); + const hasMatchingTab = agentForCheck?.sessionId && + Array.from(this.sessions.values()).some(s => s.claudeSessionId === agentForCheck.sessionId); + if (hasMatchingTab) { + this.openSubagentWindow(data.agentId); + } + } + + // Ensure connection lines are updated after window is created and DOM settles + requestAnimationFrame(() => { + this.updateConnectionLines(); + }); + + // Notify about new subagent discovery + const parentId = this.subagentParentMap.get(data.agentId); + const parentSession = parentId ? this.sessions.get(parentId) : null; + this.notificationManager?.notify({ + urgency: 'info', + category: 'subagent-spawn', + sessionId: parentId || data.sessionId, + sessionName: parentSession?.name || parentId || data.sessionId, + title: 'Subagent Spawned', + message: data.description || 'New background agent started', + }); + }, + + _onSubagentUpdated(data) { + const existing = this.subagents.get(data.agentId); + if (existing) { + // Merge updated fields (especially description) + Object.assign(existing, data); + this.subagents.set(data.agentId, existing); + } else { + this.subagents.set(data.agentId, data); + } + this.renderSubagentPanel(); + // Update floating window if open (content + header/title) + if (this.subagentWindows.has(data.agentId)) { + this.renderSubagentWindowContent(data.agentId); + this.updateSubagentWindowHeader(data.agentId); + } + }, + + _onSubagentToolCall(data) { + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'tool', ...data }); + if (activity.length > 50) activity.shift(); // Keep last 50 entries + this.subagentActivity.set(data.agentId, activity); + if (this.activeSubagentId === data.agentId) { + this.renderSubagentDetail(); + } + this.renderSubagentPanel(); + // Update floating window (debounced — tool_call events fire rapidly) + if (this.subagentWindows.has(data.agentId)) { + this.scheduleSubagentWindowRender(data.agentId); + } + }, + + _onSubagentProgress(data) { + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'progress', ...data }); + if (activity.length > 50) activity.shift(); + this.subagentActivity.set(data.agentId, activity); + if (this.activeSubagentId === data.agentId) { + this.renderSubagentDetail(); + } + // Update floating window (debounced) + if (this.subagentWindows.has(data.agentId)) { + this.scheduleSubagentWindowRender(data.agentId); + } + }, + + _onSubagentMessage(data) { + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'message', ...data }); + if (activity.length > 50) activity.shift(); + this.subagentActivity.set(data.agentId, activity); + if (this.activeSubagentId === data.agentId) { + this.renderSubagentDetail(); + } + // Update floating window (debounced) + if (this.subagentWindows.has(data.agentId)) { + this.scheduleSubagentWindowRender(data.agentId); + } + }, + + _onSubagentToolResult(data) { + // Store tool result by toolUseId for later lookup (cap at 50 per agent) + if (!this.subagentToolResults.has(data.agentId)) { + this.subagentToolResults.set(data.agentId, new Map()); + } + const resultsMap = this.subagentToolResults.get(data.agentId); + resultsMap.set(data.toolUseId, data); + if (resultsMap.size > 50) { + const oldest = resultsMap.keys().next().value; + resultsMap.delete(oldest); + } + + // Add to activity stream + const activity = this.subagentActivity.get(data.agentId) || []; + activity.push({ type: 'tool_result', ...data }); + if (activity.length > 50) activity.shift(); + this.subagentActivity.set(data.agentId, activity); + + if (this.activeSubagentId === data.agentId) { + this.renderSubagentDetail(); + } + // Update floating window (debounced) + if (this.subagentWindows.has(data.agentId)) { + this.scheduleSubagentWindowRender(data.agentId); + } + }, + + async _onSubagentCompleted(data) { + const existing = this.subagents.get(data.agentId); + if (existing) { + existing.status = 'completed'; + this.subagents.set(data.agentId, existing); + } + this.renderSubagentPanel(); + this.updateSubagentWindows(); + + // Auto-minimize completed subagent windows + if (this.subagentWindows.has(data.agentId)) { + const windowData = this.subagentWindows.get(data.agentId); + if (windowData && !windowData.minimized) { + await this.closeSubagentWindow(data.agentId); // This minimizes to tab + this.saveSubagentWindowStates(); // Persist the minimized state + } + } + + // Notify about subagent completion + const parentId = this.subagentParentMap.get(data.agentId); + const parentSession = parentId ? this.sessions.get(parentId) : null; + this.notificationManager?.notify({ + urgency: 'info', + category: 'subagent-complete', + sessionId: parentId || existing?.sessionId || data.sessionId, + sessionName: parentSession?.name || parentId || data.sessionId, + title: 'Subagent Completed', + message: existing?.description || data.description || 'Background agent finished', + }); + + // Clean up activity/tool data for completed agents after 5 minutes + // This prevents memory leaks from long-running sessions with many subagents + setTimeout(() => { + const agent = this.subagents.get(data.agentId); + // Only clean up if agent is still completed (not restarted) + if (agent?.status === 'completed') { + this.subagentActivity.delete(data.agentId); + this.subagentToolResults.delete(data.agentId); + } + }, 5 * 60 * 1000); // 5 minutes + + // Prune stale completed agents from main maps after 30 minutes + // Keeps subagents/subagentParentMap from growing unbounded in 24h sessions + setTimeout(() => { + const agent = this.subagents.get(data.agentId); + if (agent?.status === 'completed' && !this.subagentWindows.has(data.agentId)) { + this.subagents.delete(data.agentId); + this.subagentParentMap.delete(data.agentId); + } + }, 30 * 60 * 1000); // 30 minutes + }, + + // Images + _onImageDetected(data) { + console.log('[Image Detected]', data); + this.openImagePopup(data); + }, + + + // ═══════════════════════════════════════════════════════════════ + // Token Statistics Modal + // ═══════════════════════════════════════════════════════════════ + + async openTokenStats() { + try { + const response = await fetch('/api/token-stats'); + const data = await response.json(); + if (data.success) { + this.renderTokenStats(data); + document.getElementById('tokenStatsModal').classList.add('active'); + } else { + this.showToast('Failed to load token stats', 'error'); + } + } catch (err) { + console.error('Failed to fetch token stats:', err); + this.showToast('Failed to load token stats', 'error'); + } + }, + + renderTokenStats(data) { + const { daily, totals } = data; + + // Calculate period totals + const today = new Date().toISOString().split('T')[0]; + const todayData = daily.find(d => d.date === today) || { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }; + + // Last 7 days totals (for summary card) + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + const last7Days = daily.filter(d => new Date(d.date) >= sevenDaysAgo); + const weekInput = last7Days.reduce((sum, d) => sum + d.inputTokens, 0); + const weekOutput = last7Days.reduce((sum, d) => sum + d.outputTokens, 0); + const weekCost = this.estimateCost(weekInput, weekOutput); + + // Lifetime totals (from aggregate stats) + const lifetimeInput = totals.totalInputTokens; + const lifetimeOutput = totals.totalOutputTokens; + const lifetimeCost = this.estimateCost(lifetimeInput, lifetimeOutput); + + // Render summary cards + const summaryEl = document.getElementById('statsSummary'); + summaryEl.innerHTML = ` +
+ Today + ${this.formatTokens(todayData.inputTokens + todayData.outputTokens)} + ~$${todayData.estimatedCost.toFixed(2)} +
+
+ 7 Days + ${this.formatTokens(weekInput + weekOutput)} + ~$${weekCost.toFixed(2)} +
+
+ Lifetime + ${this.formatTokens(lifetimeInput + lifetimeOutput)} + ~$${lifetimeCost.toFixed(2)} +
+ `; + + // Render bar chart (last 7 days) + const chartEl = document.getElementById('statsChart'); + const daysEl = document.getElementById('statsChartDays'); + + // Get last 7 days (fill gaps with empty data) + const chartData = []; + for (let i = 6; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + const dayData = daily.find(d => d.date === dateStr); + chartData.push({ + date: dateStr, + dayName: date.toLocaleDateString('en-US', { weekday: 'short' }), + tokens: dayData ? dayData.inputTokens + dayData.outputTokens : 0, + cost: dayData ? dayData.estimatedCost : 0, + }); + } + + // Find max for scaling + const maxTokens = Math.max(...chartData.map(d => d.tokens), 1); + + chartEl.innerHTML = chartData.map(d => { + const height = Math.max((d.tokens / maxTokens) * 100, 3); + const tooltip = `${d.dayName}: ${this.formatTokens(d.tokens)} (~$${d.cost.toFixed(2)})`; + return `
`; + }).join(''); + + daysEl.innerHTML = chartData.map(d => `${d.dayName}`).join(''); + + // Render table (last 14 days with data) + const tableEl = document.getElementById('statsTable'); + const tableData = daily.slice(0, 14); + + if (tableData.length === 0) { + tableEl.innerHTML = '
No usage data recorded yet
'; + } else { + tableEl.innerHTML = ` +
+ Date + Input + Output + Cost +
+ ${tableData.map(d => { + const dateObj = new Date(d.date + 'T00:00:00'); + const dateStr = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return ` +
+ ${dateStr} + ${this.formatTokens(d.inputTokens)} + ${this.formatTokens(d.outputTokens)} + $${d.estimatedCost.toFixed(2)} +
+ `; + }).join('')} + `; + } + }, + + closeTokenStats() { + const modal = document.getElementById('tokenStatsModal'); + if (modal) { + modal.classList.remove('active'); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Monitor Panel (combined Mux Sessions + Background Tasks) + // ═══════════════════════════════════════════════════════════════ + + async toggleMonitorPanel() { + const panel = document.getElementById('monitorPanel'); + const toggleBtn = document.getElementById('monitorToggleBtn'); + panel.classList.toggle('open'); + + if (panel.classList.contains('open')) { + // Load screens and start stats collection + await this.loadMuxSessions(); + await fetch('/api/mux-sessions/stats/start', { method: 'POST' }); + this.renderTaskPanel(); + if (toggleBtn) toggleBtn.innerHTML = '▼'; // Down arrow when open + } else { + // Stop stats collection when panel is closed + await fetch('/api/mux-sessions/stats/stop', { method: 'POST' }); + if (toggleBtn) toggleBtn.innerHTML = '▲'; // Up arrow when closed + } + }, + + // Legacy alias for task panel toggle (used by session tab badge) + toggleTaskPanel() { + this.toggleMonitorPanel(); + }, + + // ═══════════════════════════════════════════════════════════════ + // Monitor Panel Detach & Drag + // ═══════════════════════════════════════════════════════════════ + + toggleMonitorDetach() { + const panel = document.getElementById('monitorPanel'); + const detachBtn = document.getElementById('monitorDetachBtn'); + + if (panel.classList.contains('detached')) { + // Re-attach to bottom + panel.classList.remove('detached'); + panel.style.top = ''; + panel.style.left = ''; + panel.style.width = ''; + panel.style.height = ''; + if (detachBtn) { + detachBtn.innerHTML = '⧉'; // Detach icon (two overlapping squares) + detachBtn.title = 'Detach panel'; + } + } else { + // Detach as floating window + panel.classList.add('detached'); + panel.classList.add('open'); // Ensure it's visible + if (detachBtn) { + detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back) + detachBtn.title = 'Attach panel'; + } + // Setup drag functionality + this.setupMonitorDrag(); + } + }, + + setupMonitorDrag() { + const panel = document.getElementById('monitorPanel'); + const header = document.getElementById('monitorPanelHeader'); + + if (!panel || !header) return; + + let isDragging = false; + let startX, startY, startLeft, startTop; + + const onStart = (e) => { + // Only drag from header, not from buttons + if (e.target.closest('button')) return; + if (!panel.classList.contains('detached')) return; + + isDragging = true; + const coords = getEventCoords(e); + startX = coords.clientX; + startY = coords.clientY; + const rect = panel.getBoundingClientRect(); + startLeft = rect.left; + startTop = rect.top; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onEnd); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + e.preventDefault(); + }; + + const onMove = (e) => { + if (!isDragging) return; + + const coords = getEventCoords(e); + const dx = coords.clientX - startX; + const dy = coords.clientY - startY; + + let newLeft = startLeft + dx; + let newTop = startTop + dy; + + // Keep within viewport bounds + const rect = panel.getBoundingClientRect(); + newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); + newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); + + panel.style.left = newLeft + 'px'; + panel.style.top = newTop + 'px'; + }; + + const onEnd = () => { + isDragging = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onEnd); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + + // Remove existing listeners before adding new ones + header.removeEventListener('mousedown', header._dragHandler); + header.removeEventListener('touchstart', header._touchDragHandler); + header._dragHandler = onStart; + header._touchDragHandler = onStart; + header.addEventListener('mousedown', onStart); + header.addEventListener('touchstart', onStart, { passive: false }); + }, + + // ═══════════════════════════════════════════════════════════════ + // Subagents Panel Detach & Drag + // ═══════════════════════════════════════════════════════════════ + + toggleSubagentsDetach() { + const panel = document.getElementById('subagentsPanel'); + const detachBtn = document.getElementById('subagentsDetachBtn'); + + if (panel.classList.contains('detached')) { + // Re-attach to bottom + panel.classList.remove('detached'); + panel.style.top = ''; + panel.style.left = ''; + panel.style.width = ''; + panel.style.height = ''; + if (detachBtn) { + detachBtn.innerHTML = '⧉'; // Detach icon + detachBtn.title = 'Detach panel'; + } + } else { + // Detach as floating window + panel.classList.add('detached'); + panel.classList.add('open'); // Ensure it's visible + if (detachBtn) { + detachBtn.innerHTML = '⊞'; // Attach icon + detachBtn.title = 'Attach panel'; + } + // Setup drag functionality + this.setupSubagentsDrag(); + } + }, + + setupSubagentsDrag() { + const panel = document.getElementById('subagentsPanel'); + const header = document.getElementById('subagentsPanelHeader'); + + if (!panel || !header) return; + + let isDragging = false; + let startX, startY, startLeft, startTop; + + const onStart = (e) => { + // Only drag from header, not from buttons + if (e.target.closest('button')) return; + if (!panel.classList.contains('detached')) return; + + isDragging = true; + const coords = getEventCoords(e); + startX = coords.clientX; + startY = coords.clientY; + const rect = panel.getBoundingClientRect(); + startLeft = rect.left; + startTop = rect.top; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onEnd); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + e.preventDefault(); + }; + + const onMove = (e) => { + if (!isDragging) return; + + const coords = getEventCoords(e); + const dx = coords.clientX - startX; + const dy = coords.clientY - startY; + + let newLeft = startLeft + dx; + let newTop = startTop + dy; + + // Keep within viewport bounds + const rect = panel.getBoundingClientRect(); + newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); + newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); + + panel.style.left = newLeft + 'px'; + panel.style.top = newTop + 'px'; + }; + + const onEnd = () => { + isDragging = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onEnd); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + + // Remove existing listeners before adding new ones + header.removeEventListener('mousedown', header._dragHandler); + header.removeEventListener('touchstart', header._touchDragHandler); + header._dragHandler = onStart; + header._touchDragHandler = onStart; + header.addEventListener('mousedown', onStart); + header.addEventListener('touchstart', onStart, { passive: false }); + }, + + renderTaskPanel() { + // Debounce renders at 100ms to prevent excessive DOM updates + if (this.renderTaskPanelTimeout) { + clearTimeout(this.renderTaskPanelTimeout); + } + this.renderTaskPanelTimeout = setTimeout(() => { + this._renderTaskPanelImmediate(); + }, 100); + }, + + _renderTaskPanelImmediate() { + const session = this.sessions.get(this.activeSessionId); + const body = document.getElementById('backgroundTasksBody'); + const stats = document.getElementById('taskPanelStats'); + const section = document.getElementById('backgroundTasksSection'); + + if (!session || !session.taskTree || session.taskTree.length === 0) { + // Hide the entire section when there are no background tasks + if (section) section.style.display = 'none'; + body.innerHTML = ''; + stats.textContent = '0 tasks'; + return; + } + + // Show the section when there are tasks + if (section) section.style.display = ''; + + const taskStats = session.taskStats || { running: 0, completed: 0, failed: 0, total: 0 }; + stats.textContent = `${taskStats.running} running, ${taskStats.completed} done`; + + // Render task tree recursively + const renderTask = (task, allTasks) => { + const statusIcon = task.status === 'running' ? '' : + task.status === 'completed' ? '✓' : '✗'; + const duration = task.endTime + ? `${((task.endTime - task.startTime) / 1000).toFixed(1)}s` + : `${((Date.now() - task.startTime) / 1000).toFixed(0)}s...`; + + let childrenHtml = ''; + if (task.children && task.children.length > 0) { + childrenHtml = '
'; + for (const childId of task.children) { + // Find child task in allTasks map + const childTask = allTasks.find(t => t.id === childId); + if (childTask) { + childrenHtml += `
${renderTask(childTask, allTasks)}
`; + } + } + childrenHtml += '
'; + } + + return ` +
+ ${statusIcon} +
+
${escapeHtml(task.description)}
+
+ ${task.subagentType} + ${duration} +
+
+
+ ${childrenHtml} + `; + }; + + // Flatten all tasks for lookup + const allTasks = this.flattenTaskTree(session.taskTree); + + // Render only root tasks (those without parents or with null parentId) + let html = '
'; + for (const task of session.taskTree) { + html += `
${renderTask(task, allTasks)}
`; + } + html += '
'; + + body.innerHTML = html; + }, + + flattenTaskTree(tasks, result = []) { + for (const task of tasks) { + result.push(task); + // Children are stored as IDs, not nested objects in taskTree + // The task tree from server already has the structure we need + } + return result; + }, + + + // ═══════════════════════════════════════════════════════════════ + // Subagent Panel (Claude Code Background Agents) + // ═══════════════════════════════════════════════════════════════ + + // Legacy alias + toggleSubagentPanel() { + this.toggleSubagentsPanel(); + }, + + updateSubagentBadge() { + const badge = this.$('subagentCountBadge'); + const activeCount = Array.from(this.subagents.values()).filter(s => s.status === 'active' || s.status === 'idle').length; + + // Update badge with active count + if (badge) { + badge.textContent = activeCount > 0 ? activeCount : ''; + } + }, + + renderSubagentPanel() { + // Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events + if (this._subagentPanelRenderTimeout) { + clearTimeout(this._subagentPanelRenderTimeout); + } + this._subagentPanelRenderTimeout = setTimeout(() => { + scheduleBackground(() => this._renderSubagentPanelImmediate()); + }, 150); + }, + + _renderSubagentPanelImmediate() { + const list = this.$('subagentList'); + if (!list) return; + + // Always update badge count + this.updateSubagentBadge(); + + // Always update monitor panel (even if subagent panel is hidden) + this.renderMonitorSubagents(); + + // If panel is not visible, don't render content + if (!this.subagentPanelVisible) { + return; + } + + // Render subagent list + if (this.subagents.size === 0) { + list.innerHTML = '
No background agents detected
'; + return; + } + + const html = []; + const sorted = Array.from(this.subagents.values()).sort((a, b) => { + // Active first, then by last activity + if (a.status === 'active' && b.status !== 'active') return -1; + if (b.status === 'active' && a.status !== 'active') return 1; + return (b.lastActivityAt || 0) - (a.lastActivityAt || 0); + }); + + for (const agent of sorted) { + const isActive = this.activeSubagentId === agent.agentId; + const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed'; + const activity = this.subagentActivity.get(agent.agentId) || []; + const lastActivity = activity[activity.length - 1]; + const lastTool = lastActivity?.type === 'tool' ? lastActivity.tool : null; + const hasWindow = this.subagentWindows.has(agent.agentId); + const canKill = agent.status === 'active' || agent.status === 'idle'; + const modelBadge = agent.modelShort + ? `${escapeHtml(agent.modelShort)}` + : ''; + + const teammateInfo = this.getTeammateInfo(agent); + const displayName = teammateInfo ? teammateInfo.name : (agent.description || agent.agentId.substring(0, 7)); + const teammateBadge = this.getTeammateBadgeHtml(agent); + const agentIcon = teammateInfo ? `●` : '🤖'; + html.push(` +
+
+ ${agentIcon} + ${escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)} + ${teammateBadge} + ${modelBadge} + ${agent.status} + ${canKill ? `` : ''} + +
+
+ ${agent.toolCallCount} tools + ${lastTool ? `${this.getToolIcon(lastTool)} ${lastTool}` : ''} +
+
+ `); + } + + list.innerHTML = html.join(''); + }, + + selectSubagent(agentId) { + this.activeSubagentId = agentId; + this.renderSubagentPanel(); + this.renderSubagentDetail(); + }, + + renderSubagentDetail() { + const detail = this.$('subagentDetail'); + if (!detail) return; + + if (!this.activeSubagentId) { + detail.innerHTML = '
Select an agent to view details
'; + return; + } + + const agent = this.subagents.get(this.activeSubagentId); + const activity = this.subagentActivity.get(this.activeSubagentId) || []; + + if (!agent) { + detail.innerHTML = '
Agent not found
'; + return; + } + + const activityHtml = activity.slice(-30).map(a => { + const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); + if (a.type === 'tool') { + const toolDetail = this.getToolDetailExpanded(a.tool, a.input, a.fullInput, a.toolUseId); + return `
+ ${time} + ${this.getToolIcon(a.tool)} + ${a.tool} + ${toolDetail.primary} + ${toolDetail.hasMore ? `` : ''} + ${toolDetail.hasMore ? `` : ''} +
`; + } else if (a.type === 'tool_result') { + const icon = a.isError ? '❌' : '📄'; + const statusClass = a.isError ? 'error' : ''; + const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; + const preview = a.preview.length > 80 ? a.preview.substring(0, 80) + '...' : a.preview; + return `
+ ${time} + ${icon} + ${a.tool || 'result'} + ${escapeHtml(preview)}${sizeInfo} +
`; + } else if (a.type === 'progress') { + // Check for hook events + const isHook = a.hookEvent || a.hookName; + const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); + const hookClass = isHook ? ' hook' : ''; + const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); + return `
+ ${time} + ${icon} + ${displayText} +
`; + } else if (a.type === 'message') { + const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text; + return `
+ ${time} + 💬 + ${escapeHtml(preview)} +
`; + } + return ''; + }).join(''); + + const detailTitle = agent.description || `Agent ${agent.agentId}`; + const modelBadge = agent.modelShort + ? `${escapeHtml(agent.modelShort)}` + : ''; + const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens) + ? `Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑` + : ''; + + detail.innerHTML = ` +
+ ${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)} + ${modelBadge} + ${agent.status} + +
+
+ Tools: ${agent.toolCallCount} + Entries: ${agent.entryCount} + Size: ${(agent.fileSize / 1024).toFixed(1)}KB + ${tokenStats} +
+
+ ${activityHtml || '
No activity yet
'} +
+ `; + }, + + toggleToolParams(toolUseId) { + const el = document.getElementById(`tool-params-${toolUseId}`); + if (!el) return; + const btn = el.previousElementSibling; + if (el.style.display === 'none') { + el.style.display = 'block'; + if (btn) btn.textContent = '▼'; + } else { + el.style.display = 'none'; + if (btn) btn.textContent = '▶'; + } + }, + + formatTokenCount(count) { + if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; + if (count >= 1000) return (count / 1000).toFixed(1) + 'k'; + return count.toString(); + }, + + formatBytes(bytes) { + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB'; + return bytes + 'B'; + }, + + getToolIcon(tool) { + const icons = { + WebSearch: '🔍', + WebFetch: '🌐', + Read: '📖', + Write: '📝', + Edit: '✏️', + Bash: '💻', + Glob: '📁', + Grep: '🔎', + Task: '🤖', + }; + return icons[tool] || '🔧'; + }, + + getToolDetail(tool, input) { + if (!input) return ''; + if (tool === 'WebSearch' && input.query) return `"${input.query}"`; + if (tool === 'WebFetch' && input.url) return input.url; + if (tool === 'Read' && input.file_path) return input.file_path; + if ((tool === 'Write' || tool === 'Edit') && input.file_path) return input.file_path; + if (tool === 'Bash' && input.command) { + const cmd = input.command; + return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd; + } + if (tool === 'Glob' && input.pattern) return input.pattern; + if (tool === 'Grep' && input.pattern) return input.pattern; + return ''; + }, + + getToolDetailExpanded(tool, input, fullInput, toolUseId) { + const primary = this.getToolDetail(tool, input); + // Check if there are additional params beyond the primary one + const primaryKeys = ['query', 'url', 'file_path', 'command', 'pattern']; + const inputKeys = Object.keys(fullInput || input || {}); + const extraKeys = inputKeys.filter(k => !primaryKeys.includes(k)); + const hasMore = extraKeys.length > 0 || (fullInput && JSON.stringify(fullInput).length > 100); + return { primary, hasMore, fullInput: fullInput || input }; + }, + + async killSubagent(agentId) { + try { + const res = await this._apiDelete(`/api/subagents/${agentId}`); + const data = await res?.json(); + if (data?.success) { + // Update local state + const agent = this.subagents.get(agentId); + if (agent) { + agent.status = 'completed'; + this.subagents.set(agentId, agent); + } + this.renderSubagentPanel(); + this.renderSubagentDetail(); + this.updateSubagentWindows(); + this.showToast(`Subagent ${agentId.substring(0, 7)} killed`, 'success'); + } else { + this.showToast(data.error || 'Failed to kill subagent', 'error'); + } + } catch (err) { + console.error('Failed to kill subagent:', err); + this.showToast('Failed to kill subagent: ' + err.message, 'error'); + } + }, + + async viewSubagentTranscript(agentId) { + try { + const res = await fetch(`/api/subagents/${agentId}/transcript?format=formatted`); + const data = await res.json(); + + if (!data.success) { + alert('Failed to load transcript'); + return; + } + + // Show in a modal or new window + const content = data.data.formatted.join('\n'); + const win = window.open('', '_blank', 'width=800,height=600'); + win.document.write(` + + + Subagent ${escapeHtml(agentId)} Transcript + + + +

Subagent ${escapeHtml(agentId)} Transcript (${data.data.entryCount} entries)

+
${escapeHtml(content)}
+ + + `); + } catch (err) { + console.error('Failed to load transcript:', err); + alert('Failed to load transcript: ' + err.message); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Subagent Parent TAB Tracking + // ═══════════════════════════════════════════════════════════════ + // + // CRITICAL: This system tracks which TAB an agent window connects to. + // The association is stored in `subagentParentMap` (agentId -> sessionId). + // The sessionId IS the tab identifier (tabs have data-id="${sessionId}"). + // Once set, this association is PERMANENT and persisted across restarts. + + /** + * Find and assign the parent TAB for a subagent. + * + * Matching strategy (in order): + * 1. Use existing stored association from subagentParentMap (permanent) + * 2. Match via claudeSessionId (agent.sessionId === session.claudeSessionId) + * 3. FALLBACK: Use the currently active session (since that's where the user typed the command) + * + * Once found, the association is stored PERMANENTLY in subagentParentMap. + */ + findParentSessionForSubagent(agentId) { + // Check if we already have a permanent association + if (this.subagentParentMap.has(agentId)) { + // Already have a parent - update agent object from stored value + const storedSessionId = this.subagentParentMap.get(agentId); + // Verify the session still exists + if (this.sessions.has(storedSessionId)) { + const agent = this.subagents.get(agentId); + if (agent && !agent.parentSessionId) { + agent.parentSessionId = storedSessionId; + const session = this.sessions.get(storedSessionId); + if (session) { + agent.parentSessionName = this.getSessionName(session); + } + this.subagents.set(agentId, agent); + this.updateSubagentWindowParent(agentId); + } + return; + } + // Stored session no longer exists - clear and re-discover + this.subagentParentMap.delete(agentId); + } + + const agent = this.subagents.get(agentId); + if (!agent) return; + + // Strategy 1: Match via claudeSessionId (most accurate) + if (agent.sessionId) { + for (const [sessionId, session] of this.sessions) { + if (session.claudeSessionId === agent.sessionId) { + // FOUND! Store this association PERMANENTLY + this.setAgentParentSessionId(agentId, sessionId); + this.updateSubagentWindowParent(agentId); + this.updateSubagentWindowVisibility(); + this.updateConnectionLines(); + return; + } + } + } + + // Strategy 2: FALLBACK - Use the currently active session + // This works because agents spawn from where the user typed the command + if (this.activeSessionId && this.sessions.has(this.activeSessionId)) { + this.setAgentParentSessionId(agentId, this.activeSessionId); + this.updateSubagentWindowParent(agentId); + this.updateSubagentWindowVisibility(); + this.updateConnectionLines(); + return; + } + + // Strategy 3: If no active session, use the first session + if (this.sessions.size > 0) { + const firstSessionId = this.sessions.keys().next().value; + this.setAgentParentSessionId(agentId, firstSessionId); + this.updateSubagentWindowParent(agentId); + this.updateSubagentWindowVisibility(); + this.updateConnectionLines(); + } + }, + + /** + * Re-check all orphan subagents (those without a parent TAB) when a session updates. + * Called when session:updated fires with claudeSessionId. + * + * Also re-validates existing associations when claudeSessionId becomes available, + * in case the fallback association was wrong. + */ + recheckOrphanSubagents() { + let anyChanged = false; + for (const [agentId, agent] of this.subagents) { + // Check if this agent has no parent in the persistent map + if (!this.subagentParentMap.has(agentId)) { + this.findParentSessionForSubagent(agentId); + if (this.subagentParentMap.has(agentId)) { + anyChanged = true; + } + } else if (agent.sessionId) { + // Agent has a stored parent, but check if we can now do a proper claudeSessionId match + // This handles the case where fallback was used but now the real parent is known + const storedParent = this.subagentParentMap.get(agentId); + const storedSession = this.sessions.get(storedParent); + + // If the stored session doesn't have a matching claudeSessionId, try to find the real match + if (storedSession && storedSession.claudeSessionId !== agent.sessionId) { + for (const [sessionId, session] of this.sessions) { + if (session.claudeSessionId === agent.sessionId) { + // Found the real parent - update the association + this.subagentParentMap.set(agentId, sessionId); + agent.parentSessionId = sessionId; + agent.parentSessionName = this.getSessionName(session); + this.subagents.set(agentId, agent); + this.updateSubagentWindowParent(agentId); + anyChanged = true; + break; + } + } + } + } + } + if (anyChanged) { + this.saveSubagentParentMap(); + this.updateConnectionLines(); + } + }, + + /** + * Update parentSessionName for all subagents belonging to a TAB. + * Called when a session is renamed to keep cached names fresh. + */ + updateSubagentParentNames(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return; + + const newName = this.getSessionName(session); + + // Skip iteration if name hasn't changed (avoids O(n) loop on every session:updated) + const cachedName = this._parentNameCache?.get(sessionId); + if (cachedName === newName) return; + if (!this._parentNameCache) this._parentNameCache = new Map(); + this._parentNameCache.set(sessionId, newName); + + for (const [agentId, storedSessionId] of this.subagentParentMap) { + if (storedSessionId === sessionId) { + const agent = this.subagents.get(agentId); + if (agent) { + agent.parentSessionName = newName; + this.subagents.set(agentId, agent); + + // Update the window header if open + const windowData = this.subagentWindows.get(agentId); + if (windowData) { + const parentNameEl = windowData.element.querySelector('.subagent-window-parent .parent-name'); + if (parentNameEl) { + parentNameEl.textContent = newName; + } + } + } + } + } + }, + + /** + * Add parent header to an agent window, showing which TAB it belongs to. + */ + updateSubagentWindowParent(agentId) { + const windowData = this.subagentWindows.get(agentId); + if (!windowData) return; + + // Get parent from persistent map (THE source of truth) + const parentSessionId = this.subagentParentMap.get(agentId); + if (!parentSessionId) return; + + const session = this.sessions.get(parentSessionId); + const parentName = session ? this.getSessionName(session) : 'Unknown'; + + // Check if parent header already exists + const win = windowData.element; + const existingParent = win.querySelector('.subagent-window-parent'); + if (existingParent) { + // Update existing + existingParent.dataset.parentSession = parentSessionId; + const nameEl = existingParent.querySelector('.parent-name'); + if (nameEl) { + nameEl.textContent = parentName; + nameEl.onclick = () => this.selectSession(parentSessionId); + } + return; + } + + // Insert new parent header after the main header + const header = win.querySelector('.subagent-window-header'); + if (header) { + const parentDiv = document.createElement('div'); + parentDiv.className = 'subagent-window-parent'; + parentDiv.dataset.parentSession = parentSessionId; + parentDiv.innerHTML = ` + from + ${escapeHtml(parentName)} + `; + header.insertAdjacentElement('afterend', parentDiv); + } + }, + + + /** + * Show/hide subagent windows based on active session. + * Behavior controlled by "Subagents for Active Tab Only" setting. + * Uses the PERSISTENT subagentParentMap for accurate tab-based visibility. + */ + updateSubagentWindowVisibility() { + const settings = this.loadAppSettingsFromStorage(); + const activeTabOnly = settings.subagentActiveTabOnly ?? true; + + for (const [agentId, windowInfo] of this.subagentWindows) { + // Get parent from PERSISTENT map (THE source of truth) + const storedParent = this.subagentParentMap.get(agentId); + const agent = this.subagents.get(agentId); + const parentSessionId = storedParent || agent?.parentSessionId; + + // Determine visibility based on setting + let shouldShow; + if (activeTabOnly) { + // Show if: no parent known yet, or parent matches active session + const hasKnownParent = !!parentSessionId; + shouldShow = !hasKnownParent || parentSessionId === this.activeSessionId; + } else { + // Show all windows (original behavior) + shouldShow = true; + } + + if (shouldShow) { + // Show window (unless it was minimized by user) + if (!windowInfo.minimized) { + windowInfo.element.style.display = 'flex'; + // Lazily re-create teammate terminal if it was disposed when hidden + if (windowInfo._lazyTerminal) { + this._restoreTeammateTerminalFromLazy(agentId); + } + } + windowInfo.hidden = false; + } else { + // Hide window (but don't close it) + // Dispose teammate terminal to free memory while hidden on inactive tab + this._disposeTeammateTerminalForMinimize(agentId); + windowInfo.element.style.display = 'none'; + windowInfo.hidden = true; + } + } + // Update connection lines after visibility changes + this.updateConnectionLines(); + // Restack mobile windows after visibility changes + this.relayoutMobileSubagentWindows(); + }, + + + // Close all subagent windows for a session (fully removes them, not minimize) + // If cleanupData is true, also remove activity and toolResults data to prevent memory leaks + closeSessionSubagentWindows(sessionId, cleanupData = false) { + const toClose = []; + for (const [agentId, _windowData] of this.subagentWindows) { + const agent = this.subagents.get(agentId); + // Check both subagent parentSessionId and subagentParentMap + // (standalone pane windows use subagentParentMap, not subagents map) + const parentFromMap = this.subagentParentMap.get(agentId); + if (agent?.parentSessionId === sessionId || parentFromMap === sessionId) { + toClose.push(agentId); + } + } + for (const agentId of toClose) { + this.forceCloseSubagentWindow(agentId); + // Clean up activity and tool results data if requested (prevents memory leaks) + if (cleanupData) { + this.subagents.delete(agentId); + this.subagentActivity.delete(agentId); + this.subagentToolResults.delete(agentId); + this.subagentParentMap.delete(agentId); + } + } + // Also clean up minimized agents for this session + this.minimizedSubagents.delete(sessionId); + this.renderSessionTabs(); + }, + + // Fully close a subagent window (removes from DOM, not minimize) + forceCloseSubagentWindow(agentId) { + const windowData = this.subagentWindows.get(agentId); + if (windowData) { + // Clean up resize observer + if (windowData.resizeObserver) { + windowData.resizeObserver.disconnect(); + } + // Clean up drag event listeners (both document-level and handle-level) + 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); + } + // Remove handle-level listeners before DOM removal + if (windowData.dragListeners.handle) { + windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown); + windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart); + } + } + windowData.element.remove(); + this.subagentWindows.delete(agentId); + } + // Clean up teammate terminal if present + const termData = this.teammateTerminals.get(agentId); + if (termData) { + if (termData.resizeObserver) { + termData.resizeObserver.disconnect(); + } + if (termData.terminal) { + try { termData.terminal.dispose(); } catch {} + } + this.teammateTerminals.delete(agentId); + } + }, + + + minimizeSubagentWindow(agentId) { + const windowData = this.subagentWindows.get(agentId); + if (windowData) { + // Dispose teammate terminal on minimize to free DOM/memory (lazy re-creation on restore) + this._disposeTeammateTerminalForMinimize(agentId); + windowData.element.style.display = 'none'; + windowData.minimized = true; + this.updateConnectionLines(); + } + }, + + + // Debounced wrapper — coalesces rapid subagent events (tool_call, progress, + // message) into a single DOM update per 100ms per agent window. + scheduleSubagentWindowRender(agentId) { + // Skip DOM updates for windows with lazy (disposed) terminals — they're minimized + const windowData = this.subagentWindows.get(agentId); + if (windowData?.minimized) return; + + if (!this._subagentWindowRenderTimeouts) this._subagentWindowRenderTimeouts = new Map(); + if (this._subagentWindowRenderTimeouts.has(agentId)) { + clearTimeout(this._subagentWindowRenderTimeouts.get(agentId)); + } + this._subagentWindowRenderTimeouts.set(agentId, setTimeout(() => { + this._subagentWindowRenderTimeouts.delete(agentId); + scheduleBackground(() => this.renderSubagentWindowContent(agentId)); + }, 100)); + }, + + renderSubagentWindowContent(agentId) { + // Skip if this window has a live terminal (don't overwrite xterm with activity HTML) + if (this.teammateTerminals.has(agentId)) return; + // Skip if this window has a lazy (disposed) terminal — it will be re-created on restore + const windowData = this.subagentWindows.get(agentId); + if (windowData?._lazyTerminal) return; + + const body = document.getElementById(`subagent-window-body-${agentId}`); + if (!body) return; + + const activity = this.subagentActivity.get(agentId) || []; + + if (activity.length === 0) { + body.innerHTML = '
No activity yet
'; + return; + } + + // Incremental rendering: track how many items are already rendered + const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0; + const maxItems = 100; + const visibleActivity = activity.slice(-maxItems); + + // If activity was trimmed or this is a fresh render, do full rebuild + if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 || + (body.children.length === 1 && body.querySelector('.subagent-empty'))) { + // Full rebuild + const html = visibleActivity.map(a => this._renderActivityItem(a)).join(''); + body.innerHTML = html; + body.dataset.renderedCount = String(visibleActivity.length); + } else { + // Incremental: only append new items + const newItems = visibleActivity.slice(renderedCount); + if (newItems.length > 0) { + const newHtml = newItems.map(a => this._renderActivityItem(a)).join(''); + body.insertAdjacentHTML('beforeend', newHtml); + body.dataset.renderedCount = String(visibleActivity.length); + + // Trim excess children from the front if over maxItems + while (body.children.length > maxItems) { + body.removeChild(body.firstChild); + } + } + } + + body.scrollTop = body.scrollHeight; + }, + + _renderActivityItem(a) { + const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); + if (a.type === 'tool') { + return `
+ ${time} + ${this.getToolIcon(a.tool)} + ${a.tool} + ${escapeHtml(this.getToolDetail(a.tool, a.input))} +
`; + } else if (a.type === 'tool_result') { + const icon = a.isError ? '❌' : '📄'; + const statusClass = a.isError ? ' error' : ''; + const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; + const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview; + return `
+ ${time} + ${icon} + ${a.tool || '→'} + ${escapeHtml(preview)}${sizeInfo} +
`; + } else if (a.type === 'progress') { + const isHook = a.hookEvent || a.hookName; + const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); + const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); + return `
+ ${time} + ${icon} + ${escapeHtml(displayText)} +
`; + } else if (a.type === 'message') { + const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; + return `
+ ${time} 💬 ${escapeHtml(preview)} +
`; + } + return ''; + }, + + // Update all open subagent windows + updateSubagentWindows() { + for (const agentId of this.subagentWindows.keys()) { + this.renderSubagentWindowContent(agentId); + this.updateSubagentWindowHeader(agentId); + } + }, + + // Update subagent window header (title and status) + updateSubagentWindowHeader(agentId) { + const agent = this.subagents.get(agentId); + if (!agent) return; + + const win = document.getElementById(`subagent-window-${agentId}`); + if (!win) return; + + // Update title/id element with description if available + const idEl = win.querySelector('.subagent-window-title .id'); + if (idEl) { + const teammateInfo = this.getTeammateInfo(agent); + const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7)); + const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle; + idEl.textContent = truncatedTitle; + } + + // Add or update teammate badge + let tmBadge = win.querySelector('.teammate-badge'); + const teammateInfo = this.getTeammateInfo(agent); + if (teammateInfo && !tmBadge) { + const titleContainer = win.querySelector('.subagent-window-title'); + if (titleContainer) { + const badge = document.createElement('span'); + badge.className = `teammate-badge teammate-color-${teammateInfo.color}`; + badge.title = `Team: ${teammateInfo.teamName}`; + badge.textContent = `@${teammateInfo.name}`; + const statusEl = titleContainer.querySelector('.status'); + if (statusEl) statusEl.insertAdjacentElement('beforebegin', badge); + } + } + + // Update full tooltip + const titleContainer = win.querySelector('.subagent-window-title'); + if (titleContainer) { + titleContainer.title = agent.description || agentId; + } + + // Update or add model badge + let modelBadge = win.querySelector('.subagent-window-title .subagent-model-badge'); + if (agent.modelShort) { + if (!modelBadge) { + modelBadge = document.createElement('span'); + modelBadge.className = `subagent-model-badge ${agent.modelShort}`; + const statusEl = win.querySelector('.subagent-window-title .status'); + if (statusEl) { + statusEl.insertAdjacentElement('beforebegin', modelBadge); + } + } + modelBadge.className = `subagent-model-badge ${agent.modelShort}`; + modelBadge.textContent = agent.modelShort; + } + + // Update status + const statusEl = win.querySelector('.subagent-window-title .status'); + if (statusEl) { + statusEl.className = `status ${agent.status}`; + statusEl.textContent = agent.status; + } + }, + + // Open windows for all active subagents + openAllActiveSubagentWindows() { + for (const [agentId, agent] of this.subagents) { + if (agent.status === 'active' && !this.subagentWindows.has(agentId)) { + this.openSubagentWindow(agentId); + } + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Agent Teams + // ═══════════════════════════════════════════════════════════════ + + /** Initialize an xterm.js terminal for a teammate's tmux pane */ + initTeammateTerminal(agentId, paneInfo, windowElement) { + const body = windowElement.querySelector('.subagent-window-body'); + if (!body) return; + + // Clear the activity log content + body.innerHTML = ''; + body.classList.add('teammate-terminal-body'); + windowElement.classList.add('has-terminal'); + + const sessionId = paneInfo.sessionId; + + // Buffer incoming terminal data until xterm is ready + const pendingData = []; + this.teammateTerminals.set(agentId, { + terminal: null, + fitAddon: null, + paneTarget: paneInfo.paneTarget, + sessionId, + resizeObserver: null, + pendingData, + }); + + // Defer terminal creation to next frame so the body element has computed dimensions + requestAnimationFrame(() => { + // Safety: if window was closed before we got here, bail out + if (!document.contains(body)) { + this.teammateTerminals.delete(agentId); + return; + } + + const terminal = new Terminal({ + theme: { + background: '#0d0d0d', + foreground: '#e0e0e0', + cursor: '#e0e0e0', + cursorAccent: '#0d0d0d', + selection: 'rgba(255, 255, 255, 0.3)', + black: '#0d0d0d', + red: '#ff6b6b', + green: '#51cf66', + yellow: '#ffd43b', + blue: '#339af0', + magenta: '#cc5de8', + cyan: '#22b8cf', + white: '#e0e0e0', + brightBlack: '#495057', + brightRed: '#ff8787', + brightGreen: '#69db7c', + brightYellow: '#ffe066', + brightBlue: '#5c7cfa', + brightMagenta: '#da77f2', + brightCyan: '#66d9e8', + brightWhite: '#ffffff', + }, + fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', + fontSize: 12, + lineHeight: 1.2, + cursorBlink: true, + cursorStyle: 'block', + scrollback: 5000, + allowTransparency: true, + allowProposedApi: true, + }); + + const fitAddon = new FitAddon.FitAddon(); + terminal.loadAddon(fitAddon); + + if (typeof Unicode11Addon !== 'undefined') { + try { + const unicode11Addon = new Unicode11Addon.Unicode11Addon(); + terminal.loadAddon(unicode11Addon); + terminal.unicode.activeVersion = '11'; + } catch (_e) { /* Unicode11 addon failed */ } + } + + try { + terminal.open(body); + } catch (err) { + console.warn('[TeammateTerminal] Failed to open terminal:', err); + this.teammateTerminals.delete(agentId); + return; + } + + // Wait for terminal renderer to fully initialize before any writes. + // xterm.js needs a few frames after open() before write() is safe. + setTimeout(() => { + try { fitAddon.fit(); } catch {} + + // Fetch initial pane buffer + fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`) + .then(r => r.json()) + .then(resp => { + if (resp.success && resp.data?.buffer) { + try { terminal.write(resp.data.buffer); } catch {} + } + }) + .catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err)); + + // Flush any data that arrived while terminal was initializing + for (const chunk of pendingData) { + try { terminal.write(chunk); } catch {} + } + pendingData.length = 0; + }, 100); + + // Forward keyboard input to the teammate's pane + terminal.onData((data) => { + fetch(`/api/sessions/${sessionId}/teammate-pane-input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }), + }).catch(err => console.error('[TeammateTerminal] Failed to send input:', err)); + }); + + // Resize observer to refit terminal when window is resized + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} }); + }); + resizeObserver.observe(body); + + // Update the stored entry with the real terminal + const entry = this.teammateTerminals.get(agentId); + if (entry) { + entry.terminal = terminal; + entry.fitAddon = fitAddon; + entry.resizeObserver = resizeObserver; + } + }); + }, + + /** Open a standalone terminal window for a tmux-pane teammate (no subagent entry needed) */ + openTeammateTerminalWindow(paneData) { + // Only open if the session has a tab in Codeman + if (!this.sessions.has(paneData.sessionId)) return; + + // Use pane target as the unique ID for this window + const windowId = `pane-${paneData.paneTarget}`; + + // If window already exists, focus it + if (this.subagentWindows.has(windowId)) { + const existing = this.subagentWindows.get(windowId); + if (existing.hidden) { + existing.element.style.display = 'flex'; + existing.hidden = false; + } + existing.element.style.zIndex = ++this.subagentWindowZIndex; + if (existing.minimized) { + this.restoreSubagentWindow(windowId); + } + return; + } + + // Calculate position + const windowCount = this.subagentWindows.size; + const windowWidth = 550; + const windowHeight = 400; + const gap = 20; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const startX = 50; + const startY = 120; + const 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; + let finalX = startX + col * (windowWidth + gap); + let finalY = startY + row * (windowHeight + gap); + finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); + finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); + + // Color badge + const colorClass = paneData.color || 'blue'; + + // Create window element + const win = document.createElement('div'); + win.className = 'subagent-window has-terminal'; + win.id = `subagent-window-${windowId}`; + win.style.zIndex = ++this.subagentWindowZIndex; + win.style.left = `${finalX}px`; + win.style.top = `${finalY}px`; + win.style.width = `${windowWidth}px`; + win.style.height = `${windowHeight}px`; + win.innerHTML = ` +
+
+ ⬤ + ${escapeHtml(paneData.teammateName)} + terminal +
+
+ +
+
+
+
+ `; + + document.body.appendChild(win); + + // Make draggable + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); + + // Make resizable if method exists + if (typeof this.makeWindowResizable === 'function') { + this.makeWindowResizable(win); + } + + // Check visibility based on active session + const settings = this.loadAppSettingsFromStorage(); + const activeTabOnly = settings.subagentActiveTabOnly ?? true; + const shouldHide = activeTabOnly && paneData.sessionId !== this.activeSessionId; + + // Store reference + this.subagentWindows.set(windowId, { + element: win, + minimized: false, + hidden: shouldHide, + dragListeners, + description: `Teammate: ${paneData.teammateName}`, + }); + + // Also add to subagentParentMap for tab-based visibility + this.subagentParentMap.set(windowId, paneData.sessionId); + + if (shouldHide) { + win.style.display = 'none'; + } + + // Focus on click + win.addEventListener('mousedown', () => { + win.style.zIndex = ++this.subagentWindowZIndex; + }); + + // Resize observer for connection lines + const resizeObserver = new ResizeObserver(() => { + this.updateConnectionLines(); + }); + resizeObserver.observe(win); + this.subagentWindows.get(windowId).resizeObserver = resizeObserver; + + // Init the xterm.js terminal (lazy if hidden) + if (shouldHide) { + // Window starts hidden — defer terminal creation until visible (lazy init) + const windowEntry = this.subagentWindows.get(windowId); + if (windowEntry) { + windowEntry._lazyTerminal = true; + windowEntry._lazyPaneTarget = paneData.paneTarget; + windowEntry._lazySessionId = paneData.sessionId; + } + } else { + this.initTeammateTerminal(windowId, paneData, win); + } + + // Animate in + requestAnimationFrame(() => { + win.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + win.style.transform = 'scale(1)'; + win.style.opacity = '1'; + }); + }, + + /** Rebuild the teammate lookup map from all team configs */ + rebuildTeammateMap() { + this.teammateMap.clear(); + for (const [teamName, team] of this.teams) { + for (const member of team.members) { + if (member.agentType !== 'team-lead') { + // Use name as key prefix for matching subagent descriptions + this.teammateMap.set(member.name, { + name: member.name, + color: member.color || 'blue', + teamName, + agentId: member.agentId, + }); + } + } + } + }, + + /** Check if a subagent is a teammate and return its info */ + getTeammateInfo(agent) { + if (!agent?.description) return null; + // Teammate descriptions start with \s]+)/); + if (!match) return null; + const teammateId = match[1]; + // Extract name from teammate_id (format: name@teamName) + const name = teammateId.split('@')[0]; + return this.teammateMap.get(name) || { name, color: 'blue', teamName: 'unknown' }; + }, + + /** Get teammate badge HTML for a subagent */ + getTeammateBadgeHtml(agent) { + const info = this.getTeammateInfo(agent); + if (!info) return ''; + return `@${escapeHtml(info.name)}`; + }, + + /** Render the team tasks panel */ + renderTeamTasksPanel() { + const panel = document.getElementById('teamTasksPanel'); + if (!panel) return; + + // Find team for active session + let activeTeam = null; + let activeTeamName = null; + if (this.activeSessionId) { + for (const [name, team] of this.teams) { + if (team.leadSessionId === this.activeSessionId) { + activeTeam = team; + activeTeamName = name; + break; + } + } + } + + if (!activeTeam) { + panel.style.display = 'none'; + return; + } + + // Set initial position and make draggable on first show + const wasHidden = panel.style.display === 'none'; + panel.style.display = 'flex'; + + if (wasHidden && !this.teamTasksDragListeners) { + // Position bottom-right + const panelWidth = 360; + const panelHeight = 300; + panel.style.left = `${Math.max(10, window.innerWidth - panelWidth - 20)}px`; + panel.style.top = `${Math.max(10, window.innerHeight - panelHeight - 70)}px`; + // Make draggable + const header = panel.querySelector('.team-tasks-header'); + if (header) { + this.teamTasksDragListeners = this.makeWindowDraggable(panel, header); + } + } + + const tasks = this.teamTasks.get(activeTeamName) || []; + const completed = tasks.filter(t => t.status === 'completed').length; + const total = tasks.length; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + const headerEl = panel.querySelector('.team-tasks-header-text'); + if (headerEl) { + const teammateCount = activeTeam.members.filter(m => m.agentType !== 'team-lead').length; + headerEl.textContent = `Team Tasks (${teammateCount} teammates)`; + } + + const progressEl = panel.querySelector('.team-tasks-progress-fill'); + if (progressEl) { + progressEl.style.width = `${pct}%`; + } + + const progressText = panel.querySelector('.team-tasks-progress-text'); + if (progressText) { + progressText.textContent = `${completed}/${total}`; + } + + const listEl = panel.querySelector('.team-tasks-list'); + if (!listEl) return; + + if (tasks.length === 0) { + listEl.innerHTML = '
No tasks yet
'; + return; + } + + const html = tasks.map(task => { + const statusIcon = task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '◉' : '○'; + const statusClass = task.status.replace('_', '-'); + const ownerBadge = task.owner + ? `${escapeHtml(task.owner)}` + : ''; + return `
+ ${statusIcon} + ${escapeHtml(task.subject)} + ${ownerBadge} +
`; + }).join(''); + + listEl.innerHTML = html; + }, + + /** Hide team tasks panel and clean up drag listeners */ + hideTeamTasksPanel() { + const panel = document.getElementById('teamTasksPanel'); + if (panel) panel.style.display = 'none'; + 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); + } + if (this.teamTasksDragListeners.handle) { + this.teamTasksDragListeners.handle.removeEventListener('mousedown', this.teamTasksDragListeners.handleMouseDown); + this.teamTasksDragListeners.handle.removeEventListener('touchstart', this.teamTasksDragListeners.handleTouchStart); + } + this.teamTasksDragListeners = null; + } + }, + + /** Get teammate color by name */ + getTeammateColor(name) { + const info = this.teammateMap.get(name); + return info?.color || 'blue'; + }, + + + // ═══════════════════════════════════════════════════════════════ + // Project Insights Panel (Bash Tools with Clickable File Paths) + // ═══════════════════════════════════════════════════════════════ + + /** + * Normalize a file path to its canonical form for comparison. + * - Expands ~ to home directory approximation + * - Resolves relative paths against working directory (case folder) + * - Normalizes . and .. components + */ + normalizeFilePath(path, workingDir) { + if (!path) return ''; + + let normalized = path.trim(); + const homeDir = '/home/' + (window.USER || 'user'); // Approximation + + // Expand ~ to home directory + if (normalized.startsWith('~/')) { + normalized = homeDir + normalized.slice(1); + } else if (normalized === '~') { + normalized = homeDir; + } + + // If not absolute, resolve against working directory (case folder) + if (!normalized.startsWith('/') && workingDir) { + normalized = workingDir + '/' + normalized; + } + + // Normalize path components (resolve . and ..) + const parts = normalized.split('/'); + const stack = []; + + for (const part of parts) { + if (part === '' || part === '.') { + continue; + } else if (part === '..') { + if (stack.length > 1) { + stack.pop(); + } + } else { + stack.push(part); + } + } + + return '/' + stack.join('/'); + }, + + /** + * Extract just the filename from a path. + */ + getFilename(path) { + const parts = path.split('/'); + return parts[parts.length - 1] || ''; + }, + + /** + * Check if a path is a "shallow root path" - an absolute path with only one + * component after root (e.g., /test.txt, /file.log). + * These are often typos where the user meant a relative path in the case folder. + */ + isShallowRootPath(path) { + if (!path.startsWith('/')) return false; + const parts = path.split('/').filter(p => p !== ''); + return parts.length === 1; + }, + + /** + * Check if a path is inside (or is) the working directory (case folder). + */ + isPathInWorkingDir(path, workingDir) { + if (!workingDir) return false; + const normalized = this.normalizeFilePath(path, workingDir); + return normalized.startsWith(workingDir + '/') || normalized === workingDir; + }, + + /** + * Smart path equivalence check. + * Two paths are considered equivalent if: + * 1. They normalize to the same path (standard case) + * 2. One is a "shallow root path" (e.g., /test.txt) and the other is the + * same filename inside the case folder - the shallow root path + * is likely a typo and they probably meant the same file. + */ + pathsAreEquivalent(path1, path2, workingDir) { + const norm1 = this.normalizeFilePath(path1, workingDir); + const norm2 = this.normalizeFilePath(path2, workingDir); + + // Standard check: exact normalized match + if (norm1 === norm2) return true; + + // Smart check: shallow root path vs case folder path with same filename + const file1 = this.getFilename(norm1); + const file2 = this.getFilename(norm2); + + if (file1 !== file2) return false; // Different filenames, can't be equivalent + + const shallow1 = this.isShallowRootPath(path1); + const shallow2 = this.isShallowRootPath(path2); + const inWorkDir1 = this.isPathInWorkingDir(norm1, workingDir); + const inWorkDir2 = this.isPathInWorkingDir(norm2, workingDir); + + // If one is shallow root (e.g., /test.txt) and other is in case folder + // with same filename, treat as equivalent (user likely made a typo) + if (shallow1 && inWorkDir2) return true; + if (shallow2 && inWorkDir1) return true; + + return false; + }, + + /** + * Pick the "better" of two paths that resolve to the same file. + * Prefers paths inside the case folder, longer/more explicit paths, and absolute paths. + */ + pickBetterPath(path1, path2, workingDir) { + // Prefer paths inside the case folder (working directory) + if (workingDir) { + const inWorkDir1 = this.isPathInWorkingDir(path1, workingDir); + const inWorkDir2 = this.isPathInWorkingDir(path2, workingDir); + if (inWorkDir1 && !inWorkDir2) return path1; + if (inWorkDir2 && !inWorkDir1) return path2; + } + + // Prefer absolute paths + const abs1 = path1.startsWith('/'); + const abs2 = path2.startsWith('/'); + if (abs1 && !abs2) return path1; + if (abs2 && !abs1) return path2; + + // Both absolute or both relative - prefer longer (more explicit) + if (path1.length !== path2.length) { + return path1.length > path2.length ? path1 : path2; + } + + // Prefer paths without ~ + if (!path1.includes('~') && path2.includes('~')) return path1; + if (!path2.includes('~') && path1.includes('~')) return path2; + + return path1; + }, + + /** + * Deduplicate file paths across all tools, keeping the "best" version. + * Uses smart equivalence checking: + * - Standard normalization for relative vs absolute paths + * - Detects likely typos (e.g., /file.txt when caseFolder/file.txt exists) + * - Prefers paths inside the case folder (working directory) + * - Prefers longer, more explicit paths + * Returns a Map of normalized path -> best raw path. + */ + deduplicateProjectInsightPaths(tools, workingDir) { + // Collect all paths with their tool IDs + const allPaths = []; + for (const tool of tools) { + for (const rawPath of tool.filePaths) { + allPaths.push({ rawPath, toolId: tool.id }); + } + } + + if (allPaths.length <= 1) { + const pathMap = new Map(); + for (const p of allPaths) { + pathMap.set(this.normalizeFilePath(p.rawPath, workingDir), p); + } + return pathMap; + } + + // Sort paths: prefer paths in case folder first, then by length (longer first) + allPaths.sort((a, b) => { + const aInWorkDir = this.isPathInWorkingDir(a.rawPath, workingDir); + const bInWorkDir = this.isPathInWorkingDir(b.rawPath, workingDir); + if (aInWorkDir && !bInWorkDir) return -1; + if (bInWorkDir && !aInWorkDir) return 1; + return b.rawPath.length - a.rawPath.length; // Longer paths first + }); + + const result = new Map(); // normalized -> { rawPath, toolId } + const seenNormalized = new Set(); + + for (const { rawPath, toolId } of allPaths) { + const normalized = this.normalizeFilePath(rawPath, workingDir); + + // Check if we've already seen an equivalent path + let isDuplicate = false; + for (const [, existing] of result) { + if (this.pathsAreEquivalent(rawPath, existing.rawPath, workingDir)) { + isDuplicate = true; + break; + } + } + + if (!isDuplicate && !seenNormalized.has(normalized)) { + result.set(normalized, { rawPath, toolId }); + seenNormalized.add(normalized); + } + } + + return result; + }, + + handleBashToolStart(sessionId, tool) { + let tools = this.projectInsights.get(sessionId) || []; + // Add new tool + tools = tools.filter(t => t.id !== tool.id); + tools.push(tool); + this.projectInsights.set(sessionId, tools); + this.renderProjectInsightsPanel(); + }, + + handleBashToolEnd(sessionId, tool) { + const tools = this.projectInsights.get(sessionId) || []; + const existing = tools.find(t => t.id === tool.id); + if (existing) { + existing.status = 'completed'; + } + this.renderProjectInsightsPanel(); + // Remove after a short delay + setTimeout(() => { + const current = this.projectInsights.get(sessionId) || []; + this.projectInsights.set(sessionId, current.filter(t => t.id !== tool.id)); + this.renderProjectInsightsPanel(); + }, 2000); + }, + + handleBashToolsUpdate(sessionId, tools) { + this.projectInsights.set(sessionId, tools); + this.renderProjectInsightsPanel(); + }, + + renderProjectInsightsPanel() { + const panel = this.$('projectInsightsPanel'); + const list = this.$('projectInsightsList'); + if (!panel || !list) return; + + // Check if panel is enabled in settings + const settings = this.loadAppSettingsFromStorage(); + const showProjectInsights = settings.showProjectInsights ?? false; + if (!showProjectInsights) { + panel.classList.remove('visible'); + this.projectInsightsPanelVisible = false; + return; + } + + // Get tools for active session only + const tools = this.projectInsights.get(this.activeSessionId) || []; + const runningTools = tools.filter(t => t.status === 'running'); + + if (runningTools.length === 0) { + panel.classList.remove('visible'); + this.projectInsightsPanelVisible = false; + return; + } + + panel.classList.add('visible'); + this.projectInsightsPanelVisible = true; + + // Get working directory for path normalization + const session = this.sessions.get(this.activeSessionId); + const workingDir = session?.workingDir || this.currentSessionWorkingDir; + + // Smart deduplication: collect all unique paths across all tools + // Paths that resolve to the same file are deduplicated, keeping the most complete version + const deduplicatedPaths = this.deduplicateProjectInsightPaths(runningTools, workingDir); + + // Build a set of paths to show (only the best version of each unique file) + const pathsToShow = new Set(Array.from(deduplicatedPaths.values()).map(p => p.rawPath)); + + const html = []; + for (const tool of runningTools) { + // Filter this tool's paths to only include those that weren't deduplicated away + const filteredPaths = tool.filePaths.filter(p => pathsToShow.has(p)); + + // Skip tools with no paths to show (all were duplicates of better paths elsewhere) + if (filteredPaths.length === 0) continue; + + const cmdDisplay = tool.command.length > 50 + ? tool.command.substring(0, 50) + '...' + : tool.command; + + html.push(` +
+
+ 💻 + ${escapeHtml(cmdDisplay)} + ${tool.status} + ${tool.timeout ? `${escapeHtml(tool.timeout)}` : ''} +
+
+ `); + + for (const path of filteredPaths) { + const fileName = path.split('/').pop(); + html.push(` + ${escapeHtml(fileName)} + `); + } + + html.push(` +
+
+ `); + } + + list.innerHTML = html.join(''); + }, + + closeProjectInsightsPanel() { + const panel = this.$('projectInsightsPanel'); + if (panel) { + panel.classList.remove('visible'); + this.projectInsightsPanelVisible = false; + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // File Browser Panel + // ═══════════════════════════════════════════════════════════════ + + async loadFileBrowser(sessionId) { + if (!sessionId) return; + + const treeEl = this.$('fileBrowserTree'); + const statusEl = this.$('fileBrowserStatus'); + if (!treeEl) return; + + // Show loading state + treeEl.innerHTML = '
Loading files...
'; + + try { + const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`); + if (!res.ok) throw new Error('Failed to load files'); + + const result = await res.json(); + if (!result.success) throw new Error(result.error || 'Failed to load files'); + + this.fileBrowserData = result.data; + this.renderFileBrowserTree(); + + // Update status + if (statusEl) { + const { totalFiles, totalDirectories, truncated } = result.data; + statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`; + } + } catch (err) { + console.error('Failed to load file browser:', err); + treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; + } + }, + + renderFileBrowserTree() { + const treeEl = this.$('fileBrowserTree'); + if (!treeEl || !this.fileBrowserData) return; + + const { tree } = this.fileBrowserData; + if (!tree || tree.length === 0) { + treeEl.innerHTML = '
No files found
'; + return; + } + + const html = []; + const filter = this.fileBrowserFilter.toLowerCase(); + + const renderNode = (node, depth) => { + const isDir = node.type === 'directory'; + const isExpanded = this.fileBrowserExpandedDirs.has(node.path); + const matchesFilter = !filter || node.name.toLowerCase().includes(filter); + + // For directories, check if any children match + let hasMatchingChildren = false; + if (isDir && filter && node.children) { + hasMatchingChildren = this.hasMatchingChild(node, filter); + } + + const shouldShow = matchesFilter || hasMatchingChildren; + const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : ''; + + const icon = isDir + ? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1') + : this.getFileIcon(node.extension); + + const expandIcon = isDir + ? `\u25B6` + : ''; + + const sizeStr = !isDir && node.size !== undefined + ? `${this.formatFileSize(node.size)}` + : ''; + + const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; + + html.push(` +
+ ${expandIcon} + ${icon} + ${escapeHtml(node.name)} + ${sizeStr} +
+ `); + + // Render children if directory is expanded + if (isDir && isExpanded && node.children) { + for (const child of node.children) { + renderNode(child, depth + 1); + } + } + }; + + for (const node of tree) { + renderNode(node, 0); + } + + treeEl.innerHTML = html.join(''); + + // Add click handlers + treeEl.querySelectorAll('.file-tree-item').forEach(item => { + item.addEventListener('click', () => { + const path = item.dataset.path; + const type = item.dataset.type; + + if (type === 'directory') { + this.toggleFileBrowserFolder(path); + } else { + this.openFilePreview(path); + } + }); + }); + }, + + hasMatchingChild(node, filter) { + if (!node.children) return false; + for (const child of node.children) { + if (child.name.toLowerCase().includes(filter)) return true; + if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true; + } + return false; + }, + + toggleFileBrowserFolder(path) { + if (this.fileBrowserExpandedDirs.has(path)) { + this.fileBrowserExpandedDirs.delete(path); + } else { + this.fileBrowserExpandedDirs.add(path); + } + this.renderFileBrowserTree(); + }, + + filterFileBrowser(value) { + this.fileBrowserFilter = value; + // Auto-expand all if filtering + if (value) { + this.expandAllDirectories(this.fileBrowserData?.tree || []); + } + this.renderFileBrowserTree(); + }, + + expandAllDirectories(nodes) { + for (const node of nodes) { + if (node.type === 'directory') { + this.fileBrowserExpandedDirs.add(node.path); + if (node.children) { + this.expandAllDirectories(node.children); + } + } + } + }, + + collapseAllDirectories() { + this.fileBrowserExpandedDirs.clear(); + }, + + toggleFileBrowserExpand() { + this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded; + const btn = this.$('fileBrowserExpandBtn'); + + if (this.fileBrowserAllExpanded) { + this.expandAllDirectories(this.fileBrowserData?.tree || []); + if (btn) btn.innerHTML = '\u229F'; // Collapse icon + } else { + this.collapseAllDirectories(); + if (btn) btn.innerHTML = '\u229E'; // Expand icon + } + this.renderFileBrowserTree(); + }, + + refreshFileBrowser() { + if (this.activeSessionId) { + this.fileBrowserExpandedDirs.clear(); + this.fileBrowserFilter = ''; + this.fileBrowserAllExpanded = false; + const searchInput = this.$('fileBrowserSearch'); + if (searchInput) searchInput.value = ''; + this.loadFileBrowser(this.activeSessionId); + } + }, + + closeFileBrowserPanel() { + const panel = this.$('fileBrowserPanel'); + if (panel) { + panel.classList.remove('visible'); + // Reset position so it reopens at default location + panel.style.left = ''; + panel.style.top = ''; + panel.style.bottom = ''; + panel.style.right = ''; + } + // Clean up drag listeners + if (this.fileBrowserDragListeners) { + const dl = this.fileBrowserDragListeners; + document.removeEventListener('mousemove', dl.move); + document.removeEventListener('mouseup', dl.up); + document.removeEventListener('touchmove', dl.touchMove); + document.removeEventListener('touchend', dl.up); + document.removeEventListener('touchcancel', dl.up); + if (dl.handle) { + dl.handle.removeEventListener('mousedown', dl.handleMouseDown); + dl.handle.removeEventListener('touchstart', dl.handleTouchStart); + if (dl._onFirstDrag) { + dl.handle.removeEventListener('mousedown', dl._onFirstDrag); + dl.handle.removeEventListener('touchstart', dl._onFirstDrag); + } + } + this.fileBrowserDragListeners = null; + } + // Save setting + const settings = this.loadAppSettingsFromStorage(); + settings.showFileBrowser = false; + this.saveAppSettingsToStorage(settings); + }, + + async openFilePreview(filePath) { + if (!this.activeSessionId || !filePath) return; + + const overlay = this.$('filePreviewOverlay'); + const titleEl = this.$('filePreviewTitle'); + const bodyEl = this.$('filePreviewBody'); + const footerEl = this.$('filePreviewFooter'); + + if (!overlay || !bodyEl) return; + + // Show overlay with loading state + overlay.classList.add('visible'); + titleEl.textContent = filePath; + bodyEl.innerHTML = '
Loading...
'; + footerEl.textContent = ''; + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`); + if (!res.ok) throw new Error('Failed to load file'); + + const result = await res.json(); + if (!result.success) throw new Error(result.error || 'Failed to load file'); + + const data = result.data; + + if (data.type === 'image') { + bodyEl.innerHTML = `${escapeHtml(filePath)}`; + footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; + } else if (data.type === 'video') { + bodyEl.innerHTML = ``; + footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; + } else if (data.type === 'binary') { + bodyEl.innerHTML = `
Binary file (${this.formatFileSize(data.size)})
Cannot preview
`; + footerEl.textContent = data.extension || 'binary'; + } else { + // Text content + this.filePreviewContent = data.content; + bodyEl.innerHTML = `
${escapeHtml(data.content)}
`; + const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : ''; + footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`; + } + } catch (err) { + console.error('Failed to preview file:', err); + bodyEl.innerHTML = `
Error: ${escapeHtml(err.message)}
`; + } + }, + + closeFilePreview() { + const overlay = this.$('filePreviewOverlay'); + if (overlay) { + overlay.classList.remove('visible'); + } + this.filePreviewContent = ''; + }, + + copyFilePreviewContent() { + if (this.filePreviewContent) { + navigator.clipboard.writeText(this.filePreviewContent).then(() => { + this.showToast('Copied to clipboard', 'success'); + }).catch(() => { + this.showToast('Failed to copy', 'error'); + }); + } + }, + + getFileIcon(ext) { + if (!ext) return '\uD83D\uDCC4'; // Default file + + const icons = { + // TypeScript/JavaScript + 'ts': '\uD83D\uDCD8', 'tsx': '\uD83D\uDCD8', 'js': '\uD83D\uDCD2', 'jsx': '\uD83D\uDCD2', + 'mjs': '\uD83D\uDCD2', 'cjs': '\uD83D\uDCD2', + // Python + 'py': '\uD83D\uDC0D', 'pyx': '\uD83D\uDC0D', 'pyw': '\uD83D\uDC0D', + // Rust/Go/C + 'rs': '\uD83E\uDD80', 'go': '\uD83D\uDC39', 'c': '\u2699\uFE0F', 'cpp': '\u2699\uFE0F', + 'h': '\u2699\uFE0F', 'hpp': '\u2699\uFE0F', + // Web + 'html': '\uD83C\uDF10', 'htm': '\uD83C\uDF10', 'css': '\uD83C\uDFA8', 'scss': '\uD83C\uDFA8', + 'sass': '\uD83C\uDFA8', 'less': '\uD83C\uDFA8', + // Data + 'json': '\uD83D\uDCCB', 'yaml': '\uD83D\uDCCB', 'yml': '\uD83D\uDCCB', 'xml': '\uD83D\uDCCB', + 'toml': '\uD83D\uDCCB', 'csv': '\uD83D\uDCCB', + // Docs + 'md': '\uD83D\uDCDD', 'markdown': '\uD83D\uDCDD', 'txt': '\uD83D\uDCDD', 'rst': '\uD83D\uDCDD', + // Images + 'png': '\uD83D\uDDBC\uFE0F', 'jpg': '\uD83D\uDDBC\uFE0F', 'jpeg': '\uD83D\uDDBC\uFE0F', + 'gif': '\uD83D\uDDBC\uFE0F', 'svg': '\uD83D\uDDBC\uFE0F', 'webp': '\uD83D\uDDBC\uFE0F', + 'ico': '\uD83D\uDDBC\uFE0F', 'bmp': '\uD83D\uDDBC\uFE0F', + // Video/Audio + 'mp4': '\uD83C\uDFAC', 'webm': '\uD83C\uDFAC', 'mov': '\uD83C\uDFAC', + 'mp3': '\uD83C\uDFB5', 'wav': '\uD83C\uDFB5', 'ogg': '\uD83C\uDFB5', + // Config/Shell + 'sh': '\uD83D\uDCBB', 'bash': '\uD83D\uDCBB', 'zsh': '\uD83D\uDCBB', + 'env': '\uD83D\uDD10', 'gitignore': '\uD83D\uDEAB', 'dockerfile': '\uD83D\uDC33', + // Lock files + 'lock': '\uD83D\uDD12', + }; + + return icons[ext.toLowerCase()] || '\uD83D\uDCC4'; + }, + + formatFileSize(bytes) { + if (bytes === undefined || bytes === null) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + }, + + + // ═══════════════════════════════════════════════════════════════ + // Log Viewer Windows (Floating File Streamers) + // ═══════════════════════════════════════════════════════════════ + + openLogViewerWindow(filePath, sessionId) { + sessionId = sessionId || this.activeSessionId; + if (!sessionId) return; + + // Create unique window ID + const windowId = `${sessionId}-${filePath.replace(/[^a-zA-Z0-9]/g, '_')}`; + + // If window already exists, focus it + if (this.logViewerWindows.has(windowId)) { + const existing = this.logViewerWindows.get(windowId); + existing.element.style.zIndex = ++this.logViewerWindowZIndex; + return; + } + + // Calculate position (cascade from top-left) + const windowCount = this.logViewerWindows.size; + const offsetX = 100 + (windowCount % 5) * 30; + const offsetY = 100 + (windowCount % 5) * 30; + + // Get filename for title + const fileName = filePath.split('/').pop(); + + // Create window element + const win = document.createElement('div'); + win.className = 'log-viewer-window'; + win.id = `log-viewer-window-${windowId}`; + win.style.left = `${offsetX}px`; + win.style.top = `${offsetY}px`; + win.style.zIndex = ++this.logViewerWindowZIndex; + + win.innerHTML = ` +
+
+ 📄 + ${escapeHtml(fileName)} + streaming +
+
+ +
+
+
+
Connecting to ${escapeHtml(filePath)}...
+
+ `; + + document.body.appendChild(win); + + // Make draggable (returns listener refs for cleanup) + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.log-viewer-window-header')); + + // Connect to SSE stream + const eventSource = new EventSource( + `/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50` + ); + + eventSource.onmessage = (e) => { + const data = JSON.parse(e.data); + const body = document.getElementById(`log-viewer-body-${windowId}`); + if (!body) return; + + switch (data.type) { + case 'connected': + body.innerHTML = ''; + break; + case 'data': + // Append data, auto-scroll + const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 10; + const content = escapeHtml(data.content); + body.innerHTML += content; + if (wasAtBottom) { + body.scrollTop = body.scrollHeight; + } + // Trim if too large + if (body.innerHTML.length > 500000) { + body.innerHTML = body.innerHTML.slice(-400000); + } + break; + case 'end': + this.updateLogViewerStatus(windowId, 'disconnected', 'ended'); + break; + case 'error': + body.innerHTML += `
${escapeHtml(data.error)}
`; + this.updateLogViewerStatus(windowId, 'error', 'error'); + break; + } + }; + + eventSource.onerror = () => { + this.updateLogViewerStatus(windowId, 'disconnected', 'connection error'); + }; + + // Store reference (including drag listeners for cleanup) + this.logViewerWindows.set(windowId, { + element: win, + eventSource, + filePath, + sessionId, + dragListeners, // Store for cleanup to prevent memory leaks + }); + }, + + updateLogViewerStatus(windowId, statusClass, statusText) { + const statusEl = document.querySelector(`#log-viewer-window-${windowId} .status`); + if (statusEl) { + statusEl.className = `status ${statusClass}`; + statusEl.textContent = statusText; + } + }, + + closeLogViewerWindow(windowId) { + const windowData = this.logViewerWindows.get(windowId); + if (!windowData) return; + + // Close SSE connection + if (windowData.eventSource) { + windowData.eventSource.close(); + } + + // Clean up drag event listeners (both document-level and handle-level) + if (windowData.dragListeners) { + document.removeEventListener('mousemove', windowData.dragListeners.move); + document.removeEventListener('mouseup', windowData.dragListeners.up); + if (windowData.dragListeners.handle) { + windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown); + windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart); + } + } + + // Remove element + windowData.element.remove(); + + // Remove from map + this.logViewerWindows.delete(windowId); + }, + + // Close all log viewer windows for a session + closeSessionLogViewerWindows(sessionId) { + const toClose = []; + for (const [windowId, data] of this.logViewerWindows) { + if (data.sessionId === sessionId) { + toClose.push(windowId); + } + } + for (const windowId of toClose) { + this.closeLogViewerWindow(windowId); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Image Popup Windows (Auto-popup for Screenshots) + // ═══════════════════════════════════════════════════════════════ + + /** + * Open a popup window to display a detected image. + * Called automatically when image:detected SSE event is received. + */ + openImagePopup(imageEvent) { + const { sessionId, filePath, relativePath, fileName, timestamp, size } = imageEvent; + + // Create unique window ID + const imageId = `${sessionId}-${timestamp}`; + + // If window already exists for this image, focus it + if (this.imagePopups.has(imageId)) { + const existing = this.imagePopups.get(imageId); + existing.element.style.zIndex = ++this.imagePopupZIndex; + return; + } + + // Cap open popups at 20 — close oldest when at limit + const MAX_IMAGE_POPUPS = 20; + if (this.imagePopups.size >= MAX_IMAGE_POPUPS) { + // Map iteration order is insertion order, so first key is oldest + const oldestId = this.imagePopups.keys().next().value; + if (oldestId) this.closeImagePopup(oldestId); + } + + // Calculate position (cascade from center, with offset for multiple popups) + const windowCount = this.imagePopups.size; + const centerX = (window.innerWidth - 600) / 2; + const centerY = (window.innerHeight - 500) / 2; + const offsetX = centerX + (windowCount % 5) * 30; + const offsetY = centerY + (windowCount % 5) * 30; + + // Get session name for display + const session = this.sessions.get(sessionId); + const sessionName = session?.name || sessionId.substring(0, 8); + + // Format file size + const sizeKB = (size / 1024).toFixed(1); + + // Build image URL using the existing file-raw endpoint + // Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images + const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`; + + // Create window element + const win = document.createElement('div'); + win.className = 'image-popup-window'; + win.id = `image-popup-${imageId}`; + win.style.left = `${offsetX}px`; + win.style.top = `${offsetY}px`; + win.style.zIndex = ++this.imagePopupZIndex; + + win.innerHTML = ` +
+
+ 🖼️ + ${escapeHtml(fileName)} + ${escapeHtml(sessionName)} + ${sizeKB} KB +
+
+ + +
+
+
+ ${escapeHtml(fileName)} +
+ `; + + document.body.appendChild(win); + + // Make draggable + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.image-popup-header')); + + // Focus on click + win.addEventListener('mousedown', () => { + win.style.zIndex = ++this.imagePopupZIndex; + }); + + // Store reference + this.imagePopups.set(imageId, { + element: win, + sessionId, + filePath, + dragListeners, + }); + }, + + /** + * Close an image popup window. + */ + closeImagePopup(imageId) { + const popupData = this.imagePopups.get(imageId); + if (!popupData) return; + + // Clean up drag event listeners (both document-level and handle-level) + 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); + } + if (popupData.dragListeners.handle) { + popupData.dragListeners.handle.removeEventListener('mousedown', popupData.dragListeners.handleMouseDown); + popupData.dragListeners.handle.removeEventListener('touchstart', popupData.dragListeners.handleTouchStart); + } + } + + // Remove element + popupData.element.remove(); + + // Remove from map + this.imagePopups.delete(imageId); + }, + + /** + * Open image in a new browser tab. + */ + openImageInNewTab(url) { + window.open(url, '_blank'); + }, + + /** + * Close all image popups for a session. + */ + closeSessionImagePopups(sessionId) { + const toClose = []; + for (const [imageId, data] of this.imagePopups) { + if (data.sessionId === sessionId) { + toClose.push(imageId); + } + } + for (const imageId of toClose) { + this.closeImagePopup(imageId); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Mux Sessions (in Monitor Panel) + // ═══════════════════════════════════════════════════════════════ + + async loadMuxSessions() { + try { + const res = await fetch('/api/mux-sessions'); + const data = await res.json(); + this.muxSessions = data.sessions || []; + this.renderMuxSessions(); + } catch (err) { + console.error('Failed to load mux sessions:', err); + } + }, + + killAllMuxSessions() { + const count = this.muxSessions?.length || 0; + if (count === 0) { + alert('No sessions to kill'); + return; + } + + // Show the kill all modal + document.getElementById('killAllCount').textContent = count; + const modal = document.getElementById('killAllModal'); + modal.classList.add('active'); + + // Activate focus trap + this.activeFocusTrap = new FocusTrap(modal); + this.activeFocusTrap.activate(); + }, + + closeKillAllModal() { + document.getElementById('killAllModal').classList.remove('active'); + + // Deactivate focus trap and restore focus + if (this.activeFocusTrap) { + this.activeFocusTrap.deactivate(); + this.activeFocusTrap = null; + } + }, + + async confirmKillAll(killMux) { + this.closeKillAllModal(); + + try { + if (killMux) { + // Kill everything including tmux sessions + const res = await fetch('/api/sessions', { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + this.sessions.clear(); + this.muxSessions = []; + this.activeSessionId = null; + try { localStorage.removeItem('codeman-active-session'); } catch {} + this.renderSessionTabs(); + this.renderMuxSessions(); + this.terminal.clear(); + this.terminal.reset(); + this.toast('All sessions and tmux killed', 'success'); + } + } else { + // Just remove tabs, keep mux sessions running + this.sessions.clear(); + this.activeSessionId = null; + try { localStorage.removeItem('codeman-active-session'); } catch {} + this.renderSessionTabs(); + this.terminal.clear(); + this.terminal.reset(); + this.toast('All tabs removed, tmux still running', 'info'); + } + } catch (err) { + console.error('Failed to kill sessions:', err); + this.toast('Failed to kill sessions: ' + err.message, 'error'); + } + }, + + + renderMuxSessions() { + // Debounce renders at 100ms to prevent excessive DOM updates + if (this.renderMuxSessionsTimeout) { + clearTimeout(this.renderMuxSessionsTimeout); + } + this.renderMuxSessionsTimeout = setTimeout(() => { + this._renderMuxSessionsImmediate(); + }, 100); + }, + + _renderMuxSessionsImmediate() { + const body = document.getElementById('muxSessionsBody'); + + if (!this.muxSessions || this.muxSessions.length === 0) { + body.innerHTML = '
No mux sessions
'; + return; + } + + let html = ''; + for (const muxSession of this.muxSessions) { + const stats = muxSession.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 }; + + // Look up rich session data by sessionId + const session = this.sessions.get(muxSession.sessionId); + const status = session ? session.status : 'unknown'; + const isWorking = session ? session.isWorking : false; + + // Status badge + let statusLabel, statusClass; + if (status === 'idle' && !isWorking) { + statusLabel = 'IDLE'; + statusClass = 'status-idle'; + } else if (status === 'busy' || isWorking) { + statusLabel = 'WORKING'; + statusClass = 'status-working'; + } else if (status === 'stopped') { + statusLabel = 'STOPPED'; + statusClass = 'status-stopped'; + } else { + statusLabel = status.toUpperCase(); + statusClass = ''; + } + + // Token and cost info + const tokens = session && session.tokens ? session.tokens : null; + const totalCost = session ? session.totalCost : 0; + const model = session ? (session.cliModel || '') : ''; + const modelShort = model.includes('opus') ? 'opus' : model.includes('sonnet') ? 'sonnet' : model.includes('haiku') ? 'haiku' : ''; + + // Ralph/Todo progress + const todoStats = session ? session.ralphTodoStats : null; + let todoHtml = ''; + if (todoStats && todoStats.total > 0) { + const pct = Math.round((todoStats.completed / todoStats.total) * 100); + todoHtml = `${todoStats.completed}/${todoStats.total} (${pct}%)`; + } + + // Format tokens + let tokenHtml = ''; + if (tokens && tokens.total > 0) { + const totalK = (tokens.total / 1000).toFixed(1); + tokenHtml = `${totalK}k tok`; + } + + // Format cost + let costHtml = ''; + if (totalCost > 0) { + costHtml = `$${totalCost.toFixed(2)}`; + } + + // Model badge + let modelHtml = ''; + if (modelShort) { + modelHtml = `${modelShort}`; + } + + html += ` +
+ ${statusLabel} +
+
${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}
+
+ ${tokenHtml} + ${costHtml} + ${todoHtml} + ${stats.memoryMB}MB + ${stats.cpuPercent}% +
+
+
+ +
+
+ `; + } + + body.innerHTML = html; + }, + + renderMonitorSubagents() { + const body = document.getElementById('monitorSubagentsBody'); + const stats = document.getElementById('monitorSubagentStats'); + if (!body) return; + + const subagents = Array.from(this.subagents.values()); + const activeCount = subagents.filter(s => s.status === 'active' || s.status === 'idle').length; + + if (stats) { + stats.textContent = `${subagents.length} tracked` + (activeCount > 0 ? `, ${activeCount} active` : ''); + } + + if (subagents.length === 0) { + body.innerHTML = '
No background agents
'; + return; + } + + let html = ''; + for (const agent of subagents) { + const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed'; + const modelBadge = agent.modelShort ? `${agent.modelShort}` : ''; + const desc = agent.description ? escapeHtml(agent.description.substring(0, 40)) : agent.agentId; + + html += ` +
+ ${agent.status} +
+
${modelBadge} ${desc}
+
+ ID: ${agent.agentId} + ${agent.toolCallCount || 0} tools +
+
+
+ ${agent.status !== 'completed' ? `` : ''} +
+
+ `; + } + + body.innerHTML = html; + }, + + async killMuxSession(sessionId) { + if (!confirm('Kill this mux session?')) return; + + try { + // Use closeSession to properly clean up both the session tab and tmux process + // (closeSession handles its own toast messaging) + await this.closeSession(sessionId, true); + } catch (err) { + // Fallback: kill mux directly if session cleanup fails + try { await fetch(`/api/mux-sessions/${sessionId}`, { method: 'DELETE' }); } catch (_ignored) {} + this.showToast('Tmux session killed', 'success'); + } + this.muxSessions = this.muxSessions.filter(s => s.sessionId !== sessionId); + this.renderMuxSessions(); + }, + + async reconcileMuxSessions() { + try { + const res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' }); + const data = await res.json(); + + if (data.dead && data.dead.length > 0) { + this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning'); + await this.loadMuxSessions(); + } else { + this.showToast('All mux sessions are alive', 'success'); + } + } catch (err) { + this.showToast('Failed to reconcile mux sessions', 'error'); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Toast + // ═══════════════════════════════════════════════════════════════ + + toggleNotifications() { + this.notificationManager?.toggleDrawer(); + }, + + // Alias for showToast + toast(message, type = 'info') { + return this.showToast(message, type); + }, + + showToast(message, type = 'info', opts = {}) { + const { duration = 3000, action } = opts; + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + + const msgSpan = document.createElement('span'); + msgSpan.textContent = message; + toast.appendChild(msgSpan); + + if (action) { + const btn = document.createElement('button'); + btn.textContent = action.label; + btn.style.cssText = 'margin-left:12px;padding:2px 10px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);border-radius:3px;color:inherit;cursor:pointer;font-size:12px'; + btn.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); }; + toast.appendChild(btn); + } + + // Cache toast container reference + if (!this._toastContainer) { + this._toastContainer = document.querySelector('.toast-container'); + if (!this._toastContainer) { + this._toastContainer = document.createElement('div'); + this._toastContainer.className = 'toast-container'; + document.body.appendChild(this._toastContainer); + } + } + this._toastContainer.appendChild(toast); + + requestAnimationFrame(() => toast.classList.add('show')); + + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 200); + }, duration); + }, + + + // ═══════════════════════════════════════════════════════════════ + // System Stats + // ═══════════════════════════════════════════════════════════════ + + startSystemStatsPolling() { + // Clear any existing interval to prevent duplicates + this.stopSystemStatsPolling(); + + // Initial fetch + this.fetchSystemStats(); + + // Poll every 2 seconds + this.systemStatsInterval = setInterval(() => { + this.fetchSystemStats(); + }, 2000); + }, + + stopSystemStatsPolling() { + if (this.systemStatsInterval) { + clearInterval(this.systemStatsInterval); + this.systemStatsInterval = null; + } + }, + + async fetchSystemStats() { + // Skip polling when system stats display is hidden + const statsEl = document.getElementById('headerSystemStats'); + if (!statsEl || statsEl.style.display === 'none') return; + + try { + const res = await fetch('/api/system/stats'); + const stats = await res.json(); + this.updateSystemStatsDisplay(stats); + } catch (err) { + // Silently fail - system stats are not critical + } + }, + + updateSystemStatsDisplay(stats) { + const cpuEl = this.$('statCpu'); + const cpuBar = this.$('statCpuBar'); + const memEl = this.$('statMem'); + const memBar = this.$('statMemBar'); + + if (cpuEl && cpuBar) { + cpuEl.textContent = `${stats.cpu}%`; + cpuBar.style.width = `${Math.min(100, stats.cpu)}%`; + + // Color classes based on usage + cpuBar.classList.remove('medium', 'high'); + cpuEl.classList.remove('high'); + if (stats.cpu > 80) { + cpuBar.classList.add('high'); + cpuEl.classList.add('high'); + } else if (stats.cpu > 50) { + cpuBar.classList.add('medium'); + } + } + + if (memEl && memBar) { + const memGB = (stats.memory.usedMB / 1024).toFixed(1); + memEl.textContent = `${memGB}G`; + memBar.style.width = `${Math.min(100, stats.memory.percent)}%`; + + // Color classes based on usage + memBar.classList.remove('medium', 'high'); + memEl.classList.remove('high'); + if (stats.memory.percent > 80) { + memBar.classList.add('high'); + memEl.classList.add('high'); + } else if (stats.memory.percent > 50) { + memBar.classList.add('medium'); + } + } + }, +}); diff --git a/src/web/public/ralph-panel.js b/src/web/public/ralph-panel.js new file mode 100644 index 00000000..2eb6705b --- /dev/null +++ b/src/web/public/ralph-panel.js @@ -0,0 +1,1221 @@ +/** + * @fileoverview Ralph state panel (progress ring, status, task cards, fix_plan.md integration), + * plan versioning (checkpoint, rollback, diff), and plan wizard agents in monitor. + * Includes 11 SSE handlers for Ralph and plan events. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class, this.ralphStates, this.activeSessionId) + * @dependency constants.js (escapeHtml) + * @loadorder 9 of 15 — loaded after respawn-ui.js, before settings-ui.js + */ + +Object.assign(CodemanApp.prototype, { + // Ralph + _onRalphLoopUpdate(data) { + // Skip if user explicitly closed this session's Ralph panel + if (this.ralphClosedSessions.has(data.sessionId)) return; + this.updateRalphState(data.sessionId, { loop: data.state }); + }, + + _onRalphTodoUpdate(data) { + // Skip if user explicitly closed this session's Ralph panel + if (this.ralphClosedSessions.has(data.sessionId)) return; + this.updateRalphState(data.sessionId, { todos: data.todos }); + }, + + _onRalphCompletionDetected(data) { + // Skip if user explicitly closed this session's Ralph panel + if (this.ralphClosedSessions.has(data.sessionId)) return; + // Prevent duplicate notifications for the same completion + const completionKey = `${data.sessionId}:${data.phrase}`; + if (this._shownCompletions?.has(completionKey)) { + return; + } + if (!this._shownCompletions) { + this._shownCompletions = new Set(); + } + this._shownCompletions.add(completionKey); + // Clear after 30 seconds to allow re-notification if loop restarts + setTimeout(() => this._shownCompletions?.delete(completionKey), 30000); + + // Update ralph state to mark loop as inactive + const existing = this.ralphStates.get(data.sessionId) || {}; + if (existing.loop) { + existing.loop.active = false; + this.updateRalphState(data.sessionId, existing); + } + + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'warning', + category: 'ralph-complete', + sessionId: data.sessionId, + sessionName: session?.name || this.getShortId(data.sessionId), + title: 'Loop Complete', + message: `Completion: ${data.phrase || 'unknown'}`, + }); + }, + + _onRalphStatusUpdate(data) { + // Skip if user explicitly closed this session's Ralph panel + if (this.ralphClosedSessions.has(data.sessionId)) return; + this.updateRalphState(data.sessionId, { statusBlock: data.block }); + }, + + _onCircuitBreakerUpdate(data) { + // Skip if user explicitly closed this session's Ralph panel + if (this.ralphClosedSessions.has(data.sessionId)) return; + this.updateRalphState(data.sessionId, { circuitBreaker: data.status }); + // Notify if circuit breaker opens + if (data.status.state === 'OPEN') { + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'critical', + category: 'circuit-breaker', + sessionId: data.sessionId, + sessionName: session?.name || this.getShortId(data.sessionId), + title: 'Circuit Breaker Open', + message: data.status.reason || 'Loop stuck - no progress detected', + }); + } + }, + + _onExitGateMet(data) { + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'warning', + category: 'exit-gate', + sessionId: data.sessionId, + sessionName: session?.name || this.getShortId(data.sessionId), + title: 'Exit Gate Met', + message: `Loop ready to exit (indicators: ${data.completionIndicators})`, + }); + }, + + // Bash tools + + // Plan orchestration + _onPlanSubagent(data) { + console.log('[Plan Subagent]', data); + this.handlePlanSubagentEvent(data); + }, + + _onPlanProgress(data) { + console.log('[Plan Progress]', data); + + // Update UI if we have a progress handler registered + if (this._planProgressHandler) { + this._planProgressHandler({ type: 'plan:progress', data }); + } + + // Also update the loading display directly for better feedback + const titleEl = document.getElementById('planLoadingTitle'); + const hintEl = document.getElementById('planLoadingHint'); + + if (titleEl && data.phase) { + const phaseLabels = { + 'parallel-analysis': 'Running parallel analysis...', + 'subagent': data.detail || 'Subagent working...', + 'synthesis': 'Synthesizing results...', + 'verification': 'Running verification...', + }; + titleEl.textContent = phaseLabels[data.phase] || data.phase; + } + if (hintEl && data.detail) { + hintEl.textContent = data.detail; + } + }, + + _onPlanStarted(data) { + console.log('[Plan Started]', data); + this.activePlanOrchestratorId = data.orchestratorId; + this.planGenerationStopped = false; // Reset flag for new generation + this.renderMonitorPlanAgents(); + }, + + _onPlanCancelled(data) { + console.log('[Plan Cancelled]', data); + if (this.activePlanOrchestratorId === data.orchestratorId) { + this.activePlanOrchestratorId = null; + } + this.renderMonitorPlanAgents(); + }, + + _onPlanCompleted(data) { + console.log('[Plan Completed]', data); + if (this.activePlanOrchestratorId === data.orchestratorId) { + this.activePlanOrchestratorId = null; + } + this.renderMonitorPlanAgents(); + }, + + + // ═══════════════════════════════════════════════════════════════ + // Enhanced Ralph Wiggum Loop Panel + // ═══════════════════════════════════════════════════════════════ + + updateRalphState(sessionId, updates) { + const existing = this.ralphStates.get(sessionId) || { loop: null, todos: [] }; + const updated = { ...existing, ...updates }; + this.ralphStates.set(sessionId, updated); + + // Re-render if this is the active session + if (sessionId === this.activeSessionId) { + this.renderRalphStatePanel(); + } + }, + + toggleRalphStatePanel() { + // Preserve xterm scroll position to prevent jump when panel height changes + const xtermViewport = this.terminal?.element?.querySelector('.xterm-viewport'); + const scrollTop = xtermViewport?.scrollTop; + + this.ralphStatePanelCollapsed = !this.ralphStatePanelCollapsed; + this.renderRalphStatePanel(); + + // Restore scroll position and refit terminal after layout change + requestAnimationFrame(() => { + // Restore xterm scroll position + if (xtermViewport && scrollTop !== undefined) { + xtermViewport.scrollTop = scrollTop; + } + // Refit terminal to new container size + if (this.terminal && this.fitAddon) { + this.fitAddon.fit(); + } + }); + }, + + async closeRalphTracker() { + if (!this.activeSessionId) return; + + // Mark this session as explicitly closed - will stay hidden until user re-enables + this.ralphClosedSessions.add(this.activeSessionId); + + // Disable tracker via API + await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-config`, { enabled: false }); + + // Clear local state and hide panel + this.ralphStates.delete(this.activeSessionId); + this.renderRalphStatePanel(); + }, + + // ═══════════════════════════════════════════════════════════════ + // @fix_plan.md Integration + // ═══════════════════════════════════════════════════════════════ + + toggleRalphMenu() { + const dropdown = document.getElementById('ralphDropdown'); + if (dropdown) { + dropdown.classList.toggle('show'); + } + }, + + closeRalphMenu() { + const dropdown = document.getElementById('ralphDropdown'); + if (dropdown) { + dropdown.classList.remove('show'); + } + }, + + async resetCircuitBreaker() { + if (!this.activeSessionId) return; + + try { + const response = await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {}); + const data = await response?.json(); + + if (data?.success) { + this.notificationManager?.notify({ + urgency: 'info', + category: 'circuit-breaker', + title: 'Reset', + message: 'Circuit breaker reset to CLOSED', + }); + } + } catch (error) { + console.error('Error resetting circuit breaker:', error); + } + }, + + /** + * Generate @fix_plan.md content and show in a modal. + */ + async showFixPlan() { + if (!this.activeSessionId) return; + + try { + const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan`); + const data = await response.json(); + + if (!data.success) { + this.notificationManager?.notify({ + urgency: 'error', + category: 'fix-plan', + title: 'Error', + message: data.error || 'Failed to generate fix plan', + }); + return; + } + + // Show in a modal + this.showFixPlanModal(data.data.content, data.data.todoCount); + } catch (error) { + console.error('Error fetching fix plan:', error); + } + }, + + /** + * Show fix plan content in a modal. + */ + showFixPlanModal(content, todoCount) { + // Create modal if it doesn't exist + let modal = document.getElementById('fixPlanModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'fixPlanModal'; + modal.className = 'modal'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + } + + document.getElementById('fixPlanContent').value = content; + document.getElementById('fixPlanStats').textContent = `${todoCount} tasks`; + modal.classList.add('show'); + }, + + closeFixPlanModal() { + const modal = document.getElementById('fixPlanModal'); + if (modal) { + modal.classList.remove('show'); + } + }, + + async copyFixPlan() { + const content = document.getElementById('fixPlanContent')?.value; + if (content) { + await navigator.clipboard.writeText(content); + this.notificationManager?.notify({ + urgency: 'info', + category: 'fix-plan', + title: 'Copied', + message: 'Fix plan copied to clipboard', + }); + } + }, + + async writeFixPlanToFile() { + if (!this.activeSessionId) return; + + try { + const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/write`, { + method: 'POST', + }); + const data = await response.json(); + + if (data.success) { + this.notificationManager?.notify({ + urgency: 'info', + category: 'fix-plan', + title: 'Written', + message: `@fix_plan.md written to ${data.data.filePath}`, + }); + this.closeFixPlanModal(); + } else { + this.notificationManager?.notify({ + urgency: 'error', + category: 'fix-plan', + title: 'Error', + message: data.error || 'Failed to write file', + }); + } + } catch (error) { + console.error('Error writing fix plan:', error); + } + }, + + async importFixPlanFromFile() { + if (!this.activeSessionId) return; + + try { + const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/read`, { + method: 'POST', + }); + const data = await response.json(); + + if (data.success) { + this.notificationManager?.notify({ + urgency: 'info', + category: 'fix-plan', + title: 'Imported', + message: `Imported ${data.data.importedCount} tasks from @fix_plan.md`, + }); + // Refresh ralph panel + this.updateRalphState(this.activeSessionId, { todos: data.data.todos }); + } else { + this.notificationManager?.notify({ + urgency: 'warning', + category: 'fix-plan', + title: 'Not Found', + message: data.error || '@fix_plan.md not found', + }); + } + } catch (error) { + console.error('Error importing fix plan:', error); + } + }, + + toggleRalphDetach() { + const panel = this.$('ralphStatePanel'); + const detachBtn = this.$('ralphDetachBtn'); + + if (!panel) return; + + if (panel.classList.contains('detached')) { + // Re-attach to original position + panel.classList.remove('detached'); + panel.style.top = ''; + panel.style.left = ''; + panel.style.width = ''; + panel.style.height = ''; + if (detachBtn) { + detachBtn.innerHTML = '⧉'; // Detach icon (two overlapping squares) + detachBtn.title = 'Detach panel'; + } + } else { + // Detach as floating window + panel.classList.add('detached'); + // Expand when detaching for better visibility + this.ralphStatePanelCollapsed = false; + panel.classList.remove('collapsed'); + if (detachBtn) { + detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back) + detachBtn.title = 'Attach panel'; + } + // Setup drag functionality + this.setupRalphDrag(); + } + this.renderRalphStatePanel(); + }, + + setupRalphDrag() { + const panel = this.$('ralphStatePanel'); + const header = this.$('ralphSummary'); + + if (!panel || !header) return; + + let isDragging = false; + let startX, startY, startLeft, startTop; + + const onMouseDown = (e) => { + // Only drag from header, not from buttons or toggle + if (e.target.closest('button') || e.target.closest('.ralph-toggle')) return; + if (!panel.classList.contains('detached')) return; + + isDragging = true; + startX = e.clientX; + startY = e.clientY; + const rect = panel.getBoundingClientRect(); + startLeft = rect.left; + startTop = rect.top; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + e.preventDefault(); + }; + + const onMouseMove = (e) => { + if (!isDragging) return; + + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + let newLeft = startLeft + dx; + let newTop = startTop + dy; + + // Keep within viewport bounds + const rect = panel.getBoundingClientRect(); + newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); + newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); + + panel.style.left = newLeft + 'px'; + panel.style.top = newTop + 'px'; + }; + + const onMouseUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + // Remove existing listeners before adding new ones + header.removeEventListener('mousedown', header._ralphDragHandler); + header._ralphDragHandler = onMouseDown; + header.addEventListener('mousedown', onMouseDown); + }, + + renderRalphStatePanel() { + // Debounce renders at 50ms to prevent excessive DOM updates + if (this.renderRalphStatePanelTimeout) { + clearTimeout(this.renderRalphStatePanelTimeout); + } + this.renderRalphStatePanelTimeout = setTimeout(() => { + this._renderRalphStatePanelImmediate(); + }, 50); + }, + + _renderRalphStatePanelImmediate() { + const panel = this.$('ralphStatePanel'); + const toggle = this.$('ralphToggle'); + + if (!panel) return; + + // If user explicitly closed this session's Ralph panel, keep it hidden + if (this.ralphClosedSessions.has(this.activeSessionId)) { + panel.style.display = 'none'; + return; + } + + const state = this.ralphStates.get(this.activeSessionId); + + // Check if there's anything to show + // Only show panel if tracker is enabled OR there's active state to display + const isEnabled = state?.loop?.enabled === true; + const hasLoop = state?.loop?.active || state?.loop?.completionPhrase; + const hasTodos = state?.todos?.length > 0; + const hasCircuitBreaker = state?.circuitBreaker && state.circuitBreaker.state !== 'CLOSED'; + const hasStatusBlock = state?.statusBlock !== undefined; + + if (!isEnabled && !hasLoop && !hasTodos && !hasCircuitBreaker && !hasStatusBlock) { + panel.style.display = 'none'; + return; + } + + panel.style.display = ''; + + // Calculate completion percentage + const todos = state?.todos || []; + const completed = todos.filter(t => t.status === 'completed').length; + const total = todos.length; + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + + // Update progress rings + this.updateRalphRing(percent); + + // Update status badge (pass completion info) + this.updateRalphStatus(state?.loop, completed, total); + + // Update stats + this.updateRalphStats(state?.loop, completed, total); + + // Update circuit breaker badge + this.updateCircuitBreakerBadge(state?.circuitBreaker); + + // Handle collapsed/expanded state + if (this.ralphStatePanelCollapsed) { + panel.classList.add('collapsed'); + if (toggle) toggle.innerHTML = '▼'; // Down arrow when collapsed (click to expand) + } else { + panel.classList.remove('collapsed'); + if (toggle) toggle.innerHTML = '▲'; // Up arrow when expanded (click to collapse) + + // Update expanded view content + this.updateRalphExpandedView(state); + } + }, + + updateRalphRing(percent) { + // Ensure percent is a valid number between 0-100 + const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); + + // Mini ring (in summary) + const miniProgress = this.$('ralphRingMiniProgress'); + const miniText = this.$('ralphRingMiniText'); + if (miniProgress) { + // Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100 + // offset = 100 means 0% visible, offset = 0 means 100% visible + const offset = 100 - safePercent; + miniProgress.style.strokeDashoffset = offset; + } + if (miniText) { + miniText.textContent = `${safePercent}%`; + } + + // Large ring (in expanded view) + const largeProgress = this.$('ralphRingProgress'); + const largePercent = this.$('ralphRingPercent'); + if (largeProgress) { + // Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264 + // offset = 264 means 0% visible, offset = 0 means 100% visible + const offset = 264 - (264 * safePercent / 100); + largeProgress.style.strokeDashoffset = offset; + } + if (largePercent) { + largePercent.textContent = `${safePercent}%`; + } + }, + + updateRalphStatus(loop, completed = 0, total = 0) { + const badge = this.$('ralphStatusBadge'); + const statusText = badge?.querySelector('.ralph-status-text'); + if (!badge || !statusText) return; + + badge.classList.remove('active', 'completed', 'tracking'); + + if (loop?.active) { + badge.classList.add('active'); + statusText.textContent = 'Running'; + } else if (total > 0 && completed === total) { + // Only show "Complete" when all todos are actually done + badge.classList.add('completed'); + statusText.textContent = 'Complete'; + } else if (loop?.enabled || total > 0) { + badge.classList.add('tracking'); + statusText.textContent = 'Tracking'; + } else { + statusText.textContent = 'Idle'; + } + }, + + updateCircuitBreakerBadge(circuitBreaker) { + // Find or create the circuit breaker badge container + let cbContainer = this.$('ralphCircuitBreakerBadge'); + if (!cbContainer) { + // Create container if it doesn't exist (we'll add it dynamically) + const summary = this.$('ralphSummary'); + if (!summary) return; + + // Check if it already exists + cbContainer = summary.querySelector('.ralph-circuit-breaker'); + if (!cbContainer) { + cbContainer = document.createElement('div'); + cbContainer.id = 'ralphCircuitBreakerBadge'; + cbContainer.className = 'ralph-circuit-breaker'; + // Insert after the status badge + const statusBadge = this.$('ralphStatusBadge'); + if (statusBadge && statusBadge.nextSibling) { + statusBadge.parentNode.insertBefore(cbContainer, statusBadge.nextSibling); + } else { + summary.appendChild(cbContainer); + } + } + } + + // Hide if no circuit breaker state or CLOSED + if (!circuitBreaker || circuitBreaker.state === 'CLOSED') { + cbContainer.style.display = 'none'; + return; + } + + cbContainer.style.display = ''; + cbContainer.classList.remove('half-open', 'open'); + + if (circuitBreaker.state === 'HALF_OPEN') { + cbContainer.classList.add('half-open'); + cbContainer.innerHTML = `⚠Warning`; + cbContainer.title = circuitBreaker.reason || 'Circuit breaker warning'; + } else if (circuitBreaker.state === 'OPEN') { + cbContainer.classList.add('open'); + cbContainer.innerHTML = `🛑Stuck`; + cbContainer.title = circuitBreaker.reason || 'Loop appears stuck'; + } + + // Add click handler to reset + cbContainer.onclick = () => this.resetCircuitBreaker(); + }, + + + updateRalphStats(loop, completed, total) { + // Time stat + const timeEl = this.$('ralphStatTime'); + if (timeEl) { + if (loop?.elapsedHours !== null && loop?.elapsedHours !== undefined) { + timeEl.textContent = this.formatRalphTime(loop.elapsedHours); + } else if (loop?.startedAt) { + const hours = (Date.now() - loop.startedAt) / (1000 * 60 * 60); + timeEl.textContent = this.formatRalphTime(hours); + } else { + timeEl.textContent = '0m'; + } + } + + // Cycles stat + const cyclesEl = this.$('ralphStatCycles'); + if (cyclesEl) { + if (loop?.maxIterations) { + cyclesEl.textContent = `${loop.cycleCount || 0}/${loop.maxIterations}`; + } else { + cyclesEl.textContent = String(loop?.cycleCount || 0); + } + } + + // Tasks stat + const tasksEl = this.$('ralphStatTasks'); + if (tasksEl) { + tasksEl.textContent = `${completed}/${total}`; + } + }, + + formatRalphTime(hours) { + if (hours < 0.0167) return '0m'; // < 1 minute + if (hours < 1) { + const minutes = Math.round(hours * 60); + return `${minutes}m`; + } + const h = Math.floor(hours); + const m = Math.round((hours - h) * 60); + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; + }, + + updateRalphExpandedView(state) { + // Update phrase + const phraseEl = this.$('ralphPhrase'); + if (phraseEl) { + phraseEl.textContent = state?.loop?.completionPhrase || '--'; + } + + // Update elapsed + const elapsedEl = this.$('ralphElapsed'); + if (elapsedEl) { + if (state?.loop?.elapsedHours !== null && state?.loop?.elapsedHours !== undefined) { + elapsedEl.textContent = this.formatRalphTime(state.loop.elapsedHours); + } else if (state?.loop?.startedAt) { + const hours = (Date.now() - state.loop.startedAt) / (1000 * 60 * 60); + elapsedEl.textContent = this.formatRalphTime(hours); + } else { + elapsedEl.textContent = '0m'; + } + } + + // Update iterations + const iterationsEl = this.$('ralphIterations'); + if (iterationsEl) { + if (state?.loop?.maxIterations) { + iterationsEl.textContent = `${state.loop.cycleCount || 0} / ${state.loop.maxIterations}`; + } else { + iterationsEl.textContent = String(state?.loop?.cycleCount || 0); + } + } + + // Update tasks count + const todos = state?.todos || []; + const completed = todos.filter(t => t.status === 'completed').length; + const tasksCountEl = this.$('ralphTasksCount'); + if (tasksCountEl) { + tasksCountEl.textContent = `${completed}/${todos.length}`; + } + + // Update plan version display if available + if (state?.loop?.planVersion) { + this.updatePlanVersionDisplay(state.loop.planVersion, state.loop.planHistoryLength || 1); + } else { + this.updatePlanVersionDisplay(null, 0); + } + + // Render task cards + this.renderRalphTasks(todos); + + // Render RALPH_STATUS block if present + this.renderRalphStatusBlock(state?.statusBlock); + }, + + renderRalphStatusBlock(statusBlock) { + // Find or create the status block container + let container = this.$('ralphStatusBlockDisplay'); + const expandedContent = this.$('ralphExpandedContent'); + + if (!statusBlock) { + // Remove container if no status block + if (container) { + container.remove(); + } + return; + } + + if (!container && expandedContent) { + container = document.createElement('div'); + container.id = 'ralphStatusBlockDisplay'; + container.className = 'ralph-status-block'; + // Insert at the top of expanded content + expandedContent.insertBefore(container, expandedContent.firstChild); + } + + if (!container) return; + + // Build status class + const statusClass = statusBlock.status === 'IN_PROGRESS' ? 'in-progress' + : statusBlock.status === 'COMPLETE' ? 'complete' + : statusBlock.status === 'BLOCKED' ? 'blocked' : ''; + + // Build tests status icon + const testsIcon = statusBlock.testsStatus === 'PASSING' ? '✅' + : statusBlock.testsStatus === 'FAILING' ? '❌' + : '⏸'; + + // Build work type icon + const workIcon = statusBlock.workType === 'IMPLEMENTATION' ? '🔧' + : statusBlock.workType === 'TESTING' ? '🧪' + : statusBlock.workType === 'DOCUMENTATION' ? '📝' + : statusBlock.workType === 'REFACTORING' ? '♻️' : '📋'; + + let html = ` +
+ RALPH_STATUS + ${escapeHtml(statusBlock.status)} + ${statusBlock.exitSignal ? '🚪 EXIT' : ''} +
+
+ ${workIcon} ${escapeHtml(statusBlock.workType)} + 📁 ${statusBlock.filesModified} files + ✓ ${escapeHtml(String(statusBlock.tasksCompletedThisLoop))} tasks + ${testsIcon} Tests: ${escapeHtml(statusBlock.testsStatus)} +
+ `; + + if (statusBlock.recommendation) { + html += `
${escapeHtml(statusBlock.recommendation)}
`; + } + + container.innerHTML = html; + }, + + renderRalphTasks(todos) { + const grid = this.$('ralphTasksGrid'); + if (!grid) return; + + if (todos.length === 0) { + if (grid.children.length !== 1 || !grid.querySelector('.ralph-state-empty')) { + grid.innerHTML = '
No tasks detected
'; + } + return; + } + + // Sort: by priority (P0 > P1 > P2 > null), then by status (in_progress > pending > completed) + const priorityOrder = { 'P0': 0, 'P1': 1, 'P2': 2, null: 3 }; + const statusOrder = { in_progress: 0, pending: 1, completed: 2 }; + const sorted = [...todos].sort((a, b) => { + const priA = priorityOrder[a.priority] ?? 3; + const priB = priorityOrder[b.priority] ?? 3; + if (priA !== priB) return priA - priB; + return (statusOrder[a.status] || 1) - (statusOrder[b.status] || 1); + }); + + // Always do full rebuild for enhanced features + const fragment = document.createDocumentFragment(); + + sorted.forEach((todo, idx) => { + const card = this.createRalphTaskCard(todo, idx); + fragment.appendChild(card); + }); + + grid.innerHTML = ''; + grid.appendChild(fragment); + }, + + createRalphTaskCard(todo, index) { + const card = document.createElement('div'); + const statusClass = `task-${todo.status.replace('_', '-')}`; + const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : ''; + card.className = `ralph-task-card ${statusClass} ${priorityClass}`.trim(); + card.dataset.taskId = todo.id || index; + + // Status icon + const iconSpan = document.createElement('span'); + iconSpan.className = 'ralph-task-icon'; + iconSpan.textContent = this.getRalphTaskIcon(todo.status); + card.appendChild(iconSpan); + + // Priority badge if present + if (todo.priority) { + const prioritySpan = document.createElement('span'); + prioritySpan.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; + prioritySpan.textContent = todo.priority; + card.appendChild(prioritySpan); + } + + // Task content + const contentSpan = document.createElement('span'); + contentSpan.className = 'ralph-task-content'; + contentSpan.textContent = todo.content; + card.appendChild(contentSpan); + + // Attempts indicator (if > 0) + if (todo.attempts && todo.attempts > 0) { + const attemptsSpan = document.createElement('span'); + attemptsSpan.className = 'ralph-task-attempts'; + if (todo.lastError) { + attemptsSpan.classList.add('has-errors'); + attemptsSpan.title = `Last error: ${todo.lastError}`; + } + attemptsSpan.textContent = `#${todo.attempts}`; + card.appendChild(attemptsSpan); + } + + // Verification badge (if has verification criteria) + if (todo.verificationCriteria) { + const verifySpan = document.createElement('span'); + verifySpan.className = 'ralph-task-verify-badge'; + verifySpan.title = `Verify: ${todo.verificationCriteria}`; + verifySpan.textContent = '✓'; + card.appendChild(verifySpan); + } + + // Dependencies indicator + if (todo.dependencies && todo.dependencies.length > 0) { + const depsSpan = document.createElement('span'); + depsSpan.className = 'ralph-task-deps-indicator'; + depsSpan.title = `Depends on: ${todo.dependencies.join(', ')}`; + depsSpan.textContent = `↗${todo.dependencies.length}`; + card.appendChild(depsSpan); + } + + // Quick action buttons (shown on hover) + const actions = document.createElement('div'); + actions.className = 'ralph-task-actions'; + + if (todo.status !== 'completed') { + const completeBtn = document.createElement('button'); + completeBtn.className = 'ralph-task-action-btn'; + completeBtn.textContent = '✓'; + completeBtn.title = 'Mark complete'; + completeBtn.onclick = (e) => { + e.stopPropagation(); + this.updateRalphTaskStatus(todo.id, 'completed'); + }; + actions.appendChild(completeBtn); + } + + if (todo.status === 'completed') { + const reopenBtn = document.createElement('button'); + reopenBtn.className = 'ralph-task-action-btn'; + reopenBtn.textContent = '↺'; + reopenBtn.title = 'Reopen'; + reopenBtn.onclick = (e) => { + e.stopPropagation(); + this.updateRalphTaskStatus(todo.id, 'pending'); + }; + actions.appendChild(reopenBtn); + } + + if (todo.lastError) { + const retryBtn = document.createElement('button'); + retryBtn.className = 'ralph-task-action-btn'; + retryBtn.textContent = '↻'; + retryBtn.title = 'Retry (clear error)'; + retryBtn.onclick = (e) => { + e.stopPropagation(); + this.retryRalphTask(todo.id); + }; + actions.appendChild(retryBtn); + } + + card.appendChild(actions); + + return card; + }, + + // Update a Ralph task's status via API + async updateRalphTaskStatus(taskId, newStatus) { + if (!this.activeSessionId) return; + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to update task'); + } + + this.showToast(`Task ${newStatus === 'completed' ? 'completed' : 'reopened'}`, 'success'); + } catch (err) { + this.showToast('Failed to update task: ' + err.message, 'error'); + } + }, + + // Retry a failed Ralph task (clear error, reset attempts) + async retryRalphTask(taskId) { + if (!this.activeSessionId) return; + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ attempts: 0, lastError: null, status: 'pending' }) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to retry task'); + } + + this.showToast('Task reset for retry', 'success'); + } catch (err) { + this.showToast('Failed to retry task: ' + err.message, 'error'); + } + }, + + getRalphTaskIcon(status) { + switch (status) { + case 'completed': return '✓'; + case 'in_progress': return '◐'; + case 'pending': + default: return '○'; + } + }, + + // Legacy method for backwards compatibility + getTodoIcon(status) { + return this.getRalphTaskIcon(status); + }, + + + // ═══════════════════════════════════════════════════════════════ + // Plan Versioning + // ═══════════════════════════════════════════════════════════════ + + // Update the plan version display in the Ralph panel + updatePlanVersionDisplay(version, historyLength) { + const versionRow = this.$('ralphVersionRow'); + const versionBadge = this.$('ralphPlanVersion'); + const rollbackBtn = this.$('ralphRollbackBtn'); + + if (!versionRow) return; + + if (version && version > 0) { + versionRow.style.display = ''; + if (versionBadge) versionBadge.textContent = `v${version}`; + if (rollbackBtn) { + rollbackBtn.style.display = historyLength > 1 ? '' : 'none'; + } + } else { + versionRow.style.display = 'none'; + } + }, + + // Show plan history dropdown + async showPlanHistory() { + if (!this.activeSessionId) return; + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/history`); + const data = await res.json(); + + if (data.error) { + this.showToast('Failed to load plan history: ' + data.error, 'error'); + return; + } + + const history = data.history || []; + if (history.length === 0) { + this.showToast('No plan history available', 'info'); + return; + } + + // Show history dropdown modal + this.showPlanHistoryModal(history, data.currentVersion); + } catch (err) { + this.showToast('Failed to load plan history: ' + err.message, 'error'); + } + }, + + // Show the plan history modal + showPlanHistoryModal(history, currentVersion) { + // Remove existing modal if present + const existing = document.getElementById('planHistoryModal'); + if (existing) existing.remove(); + + const modal = document.createElement('div'); + modal.id = 'planHistoryModal'; + modal.className = 'modal active'; + modal.innerHTML = ` + + + `; + + document.body.appendChild(modal); + }, + + closePlanHistoryModal() { + const modal = document.getElementById('planHistoryModal'); + if (modal) modal.remove(); + }, + + // Rollback to a specific plan version + async rollbackToPlanVersion(version) { + if (!this.activeSessionId) return; + + if (!confirm(`Rollback to plan version ${version}? Current changes will be preserved in history.`)) { + return; + } + + try { + const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/rollback/${version}`, { + method: 'POST' + }); + const data = await res.json(); + + if (data.error) { + this.showToast('Failed to rollback: ' + data.error, 'error'); + return; + } + + this.showToast(`Rolled back to plan v${version}`, 'success'); + this.closePlanHistoryModal(); + + // Refresh the plan display + this.renderRalphStatePanel(); + } catch (err) { + this.showToast('Failed to rollback: ' + err.message, 'error'); + } + }, + + // Format relative time (e.g., "2 mins ago", "1 hour ago") + formatRelativeTime(timestamp) { + if (!timestamp) return ''; + + const now = Date.now(); + const diff = now - timestamp; + + const mins = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + if (hours < 24) return `${hours}h ago`; + return `${days}d ago`; + }, + + + // ═══════════════════════════════════════════════════════════════ + // Plan Wizard Agents in Monitor + // ═══════════════════════════════════════════════════════════════ + + renderMonitorPlanAgents() { + const section = document.getElementById('monitorPlanAgentsSection'); + const body = document.getElementById('monitorPlanAgentsBody'); + const stats = document.getElementById('monitorPlanAgentStats'); + if (!section || !body) return; + + const planAgents = Array.from(this.planSubagents?.values() || []); + const hasActiveOrchestrator = !!this.activePlanOrchestratorId; + + // Show section only if there are plan agents or active orchestrator + if (planAgents.length === 0 && !hasActiveOrchestrator) { + section.style.display = 'none'; + return; + } + + section.style.display = ''; + + const activeCount = planAgents.filter(a => a.status === 'running').length; + const completedCount = planAgents.filter(a => a.status === 'completed' || a.status === 'failed').length; + + if (stats) { + if (hasActiveOrchestrator) { + stats.textContent = `${activeCount} running, ${completedCount} done`; + } else { + stats.textContent = `${planAgents.length} total`; + } + } + + if (planAgents.length === 0) { + body.innerHTML = `
${hasActiveOrchestrator ? 'Plan generation starting...' : 'No plan agents'}
`; + return; + } + + let html = ''; + for (const agent of planAgents) { + const statusClass = agent.status === 'running' ? 'active' : agent.status === 'completed' ? 'completed' : 'error'; + const agentLabel = agent.agentType || agent.agentId; + const modelBadge = agent.model ? `opus` : ''; + const detail = agent.detail ? escapeHtml(agent.detail.substring(0, 50)) : ''; + const duration = agent.durationMs ? `${(agent.durationMs / 1000).toFixed(1)}s` : ''; + const itemCount = agent.itemCount ? `${agent.itemCount} items` : ''; + + html += ` +
+ ${agent.status || 'pending'} +
+
${modelBadge} ${escapeHtml(agentLabel)}
+
+ ${detail ? `${detail}` : ''} + ${itemCount ? `${itemCount}` : ''} + ${duration ? `${duration}` : ''} +
+
+
+ `; + } + + body.innerHTML = html; + }, + + async cancelPlanFromMonitor() { + if (!this.activePlanOrchestratorId && this.planSubagents?.size === 0) { + this.showToast('No active plan generation', 'info'); + return; + } + + if (!confirm('Cancel plan generation and close all plan agent windows?')) return; + + // Cancel the plan generation (reuse existing method) + await this.cancelPlanGeneration(); + + // Also force close the wizard if it's open + const wizardModal = document.getElementById('ralphWizardModal'); + if (wizardModal?.classList.contains('active')) { + this.closeRalphWizard(); + } + + // Update monitor display + this.renderMonitorPlanAgents(); + this.showToast('Plan generation cancelled', 'success'); + }, +}); diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index bf6e8f29..80cecd53 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -19,7 +19,7 @@ * @dependency app.js (CodemanApp class must be defined) * @dependency keyboard-accessory.js (FocusTrap class for modal focus management) * @dependency constants.js (escapeHtml) - * @loadorder 7 of 9 — loaded after app.js, before api-client.js + * @loadorder 13 of 15 — loaded after session-ui.js, before api-client.js */ // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/respawn-ui.js b/src/web/public/respawn-ui.js new file mode 100644 index 00000000..930ca824 --- /dev/null +++ b/src/web/public/respawn-ui.js @@ -0,0 +1,1185 @@ +/** + * @fileoverview Respawn banner (state display, countdown timers, action log, detection layers), + * respawn presets (CRUD, built-in presets), and run summary modal (timeline, export). + * Includes 12 SSE handlers for respawn events. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class, this.respawnStatus, this.respawnTimers, this.respawnCountdownTimers) + * @dependency constants.js (escapeHtml, SSE_EVENTS) + * @loadorder 8 of 15 — loaded after terminal-ui.js, before ralph-panel.js + */ + +Object.assign(CodemanApp.prototype, { + // Respawn + _onRespawnStarted(data) { + this.respawnStatus[data.sessionId] = data.status; + if (data.sessionId === this.activeSessionId) { + this.showRespawnBanner(); + } + }, + + _onRespawnStopped(data) { + delete this.respawnStatus[data.sessionId]; + if (data.sessionId === this.activeSessionId) { + this.hideRespawnBanner(); + } + }, + + _onRespawnStateChanged(data) { + if (this.respawnStatus[data.sessionId]) { + this.respawnStatus[data.sessionId].state = data.state; + } + if (data.sessionId === this.activeSessionId) { + this.updateRespawnBanner(data.state); + } + }, + + _onRespawnCycleStarted(data) { + if (this.respawnStatus[data.sessionId]) { + this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber; + } + if (data.sessionId === this.activeSessionId) { + document.getElementById('respawnCycleCount').textContent = data.cycleNumber; + } + }, + + _onRespawnBlocked(data) { + const session = this.sessions.get(data.sessionId); + const reasonMap = { + circuit_breaker_open: 'Circuit Breaker Open', + exit_signal: 'Exit Signal Detected', + status_blocked: 'Claude Reported BLOCKED', + }; + const title = reasonMap[data.reason] || 'Respawn Blocked'; + this.notificationManager?.notify({ + urgency: 'critical', + category: 'respawn-blocked', + sessionId: data.sessionId, + sessionName: session?.name || this.getShortId(data.sessionId), + title, + message: data.details, + }); + // Update respawn panel to show blocked state + if (data.sessionId === this.activeSessionId) { + const stateEl = document.getElementById('respawnStateLabel'); + if (stateEl) { + stateEl.textContent = title; + stateEl.classList.add('respawn-blocked'); + } + } + }, + + _onRespawnAutoAcceptSent(data) { + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'info', + category: 'auto-accept', + sessionId: data.sessionId, + sessionName: session?.name || this.getShortId(data.sessionId), + title: 'Plan Accepted', + message: `Accepted plan mode for ${session?.name || 'session'}`, + }); + }, + + _onRespawnDetectionUpdate(data) { + if (this.respawnStatus[data.sessionId]) { + this.respawnStatus[data.sessionId].detection = data.detection; + } + if (data.sessionId === this.activeSessionId) { + this.updateDetectionDisplay(data.detection); + } + }, + + // Merged handler for respawn:timerStarted — handles both run timers (data.endAt) + // and controller countdown timers (data.timer). Previously registered as two + // separate addListener calls (duplicate event bug). + _onRespawnTimerStarted(data) { + // Run timer (timed respawn runs) + if (data.endAt) { + this.respawnTimers[data.sessionId] = { + endAt: data.endAt, + startedAt: data.startedAt, + durationMinutes: data.durationMinutes + }; + if (data.sessionId === this.activeSessionId) { + this.showRespawnTimer(); + } + } + // Controller countdown timer (internal timers) + if (data.timer) { + const { sessionId, timer } = data; + if (!this.respawnCountdownTimers[sessionId]) { + this.respawnCountdownTimers[sessionId] = {}; + } + this.respawnCountdownTimers[sessionId][timer.name] = { + endsAt: timer.endsAt, + totalMs: timer.durationMs, + reason: timer.reason + }; + if (sessionId === this.activeSessionId) { + this.updateCountdownTimerDisplay(); + this.startCountdownInterval(); + } + } + }, + + _onRespawnTimerCancelled(data) { + const { sessionId, timerName } = data; + if (this.respawnCountdownTimers[sessionId]) { + delete this.respawnCountdownTimers[sessionId][timerName]; + } + if (sessionId === this.activeSessionId) { + this.updateCountdownTimerDisplay(); + } + }, + + _onRespawnTimerCompleted(data) { + const { sessionId, timerName } = data; + if (this.respawnCountdownTimers[sessionId]) { + delete this.respawnCountdownTimers[sessionId][timerName]; + } + if (sessionId === this.activeSessionId) { + this.updateCountdownTimerDisplay(); + } + }, + + _onRespawnError(data) { + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'critical', + category: 'session-error', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Respawn Error', + message: data.error || data.message || 'Respawn encountered an error', + }); + }, + + _onRespawnActionLog(data) { + const { sessionId, action } = data; + this.addActionLogEntry(sessionId, action); + if (sessionId === this.activeSessionId) { + this.updateCountdownTimerDisplay(); // Show row if hidden + this.updateActionLogDisplay(); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Respawn Banner + // ═══════════════════════════════════════════════════════════════ + + showRespawnBanner() { + this.$('respawnBanner').style.display = 'flex'; + // Also show timer if there's a timed respawn + if (this.activeSessionId && this.respawnTimers[this.activeSessionId]) { + this.showRespawnTimer(); + } + // Show tokens if session has token data + const session = this.sessions.get(this.activeSessionId); + if (session && session.tokens) { + this.updateRespawnTokens(session.tokens); + } + }, + + hideRespawnBanner() { + this.$('respawnBanner').style.display = 'none'; + this.hideRespawnTimer(); + }, + + // Human-friendly state labels + getStateLabel(state) { + const labels = { + 'stopped': 'Stopped', + 'watching': 'Watching', + 'confirming_idle': 'Confirming idle', + 'ai_checking': 'AI checking', + 'sending_update': 'Sending prompt', + 'waiting_update': 'Running prompt', + 'sending_clear': 'Clearing context', + 'waiting_clear': 'Clearing...', + 'sending_init': 'Initializing', + 'waiting_init': 'Initializing...', + 'monitoring_init': 'Waiting for work', + 'sending_kickstart': 'Kickstarting', + 'waiting_kickstart': 'Kickstarting...', + }; + return labels[state] || state.replace(/_/g, ' '); + }, + + updateRespawnBanner(state) { + const stateEl = this.$('respawnState'); + stateEl.textContent = this.getStateLabel(state); + // Clear blocked state when state changes (resumed from blocked) + stateEl.classList.remove('respawn-blocked'); + }, + + updateDetectionDisplay(detection) { + if (!detection) return; + + const statusEl = this.$('detectionStatus'); + const waitingEl = this.$('detectionWaiting'); + const confidenceEl = this.$('detectionConfidence'); + const aiCheckEl = document.getElementById('detectionAiCheck'); + const hookEl = document.getElementById('detectionHook'); + + // Hook-based detection indicator (highest priority signals) + if (hookEl) { + if (detection.stopHookReceived || detection.idlePromptReceived) { + const hookType = detection.idlePromptReceived ? 'idle' : 'stop'; + hookEl.textContent = `🎯 ${hookType} hook`; + hookEl.className = 'detection-hook hook-active'; + hookEl.style.display = ''; + } else { + hookEl.style.display = 'none'; + } + } + + // Simplified status - only show when meaningful + if (detection.statusText && detection.statusText !== 'Watching...') { + statusEl.textContent = detection.statusText; + statusEl.style.display = ''; + } else { + statusEl.style.display = 'none'; + } + + // Hide "waiting for" text - it's redundant with the state label + waitingEl.style.display = 'none'; + + // Show confidence only when confirming (>0%) + const confidence = detection.confidenceLevel || 0; + if (confidence > 0) { + confidenceEl.textContent = `${confidence}%`; + confidenceEl.style.display = ''; + confidenceEl.className = 'detection-confidence'; + // Hook signals give 100% confidence + if (detection.stopHookReceived || detection.idlePromptReceived) { + confidenceEl.classList.add('hook-confirmed'); + } else if (confidence >= 60) { + confidenceEl.classList.add('high'); + } else if (confidence >= 30) { + confidenceEl.classList.add('medium'); + } + } else { + confidenceEl.style.display = 'none'; + } + + // AI check display - compact format + if (aiCheckEl && detection.aiCheck) { + const ai = detection.aiCheck; + let aiText = ''; + let aiClass = 'detection-ai-check'; + + if (ai.status === 'checking') { + aiText = '🔍 AI checking...'; + aiClass += ' ai-checking'; + } else if (ai.status === 'cooldown' && ai.cooldownEndsAt) { + const remaining = Math.ceil((ai.cooldownEndsAt - Date.now()) / 1000); + if (remaining > 0) { + if (ai.lastVerdict === 'WORKING') { + aiText = `⏳ Working, retry ${remaining}s`; + aiClass += ' ai-working'; + } else { + aiText = `✓ Idle, wait ${remaining}s`; + aiClass += ' ai-idle'; + } + } + } else if (ai.status === 'disabled') { + aiText = '⚠ AI disabled'; + aiClass += ' ai-disabled'; + } else if (ai.lastVerdict && ai.lastCheckTime) { + const ago = Math.round((Date.now() - ai.lastCheckTime) / 1000); + if (ago < 120) { + aiText = ai.lastVerdict === 'IDLE' + ? `✓ Idle (${ago}s)` + : `⏳ Working (${ago}s)`; + aiClass += ai.lastVerdict === 'IDLE' ? ' ai-idle' : ' ai-working'; + } + } + + aiCheckEl.textContent = aiText; + aiCheckEl.className = aiClass; + aiCheckEl.style.display = aiText ? '' : 'none'; + } else if (aiCheckEl) { + aiCheckEl.style.display = 'none'; + } + + // Manage row2 visibility - hide if nothing visible + const row2 = this.$('respawnStatusRow2'); + if (row2) { + const hasVisibleContent = + (hookEl && hookEl.style.display !== 'none') || + (aiCheckEl && aiCheckEl.style.display !== 'none') || + (statusEl && statusEl.style.display !== 'none') || + (this.respawnCountdownTimers[this.activeSessionId] && + Object.keys(this.respawnCountdownTimers[this.activeSessionId]).length > 0); + row2.style.display = hasVisibleContent ? '' : 'none'; + } + }, + + showRespawnTimer() { + const timerEl = this.$('respawnTimer'); + timerEl.style.display = ''; + this.updateRespawnTimer(); + // Update every second + if (this.respawnTimerInterval) clearInterval(this.respawnTimerInterval); + this.respawnTimerInterval = setInterval(() => this.updateRespawnTimer(), 1000); + }, + + hideRespawnTimer() { + this.$('respawnTimer').style.display = 'none'; + if (this.respawnTimerInterval) { + clearInterval(this.respawnTimerInterval); + this.respawnTimerInterval = null; + } + }, + + updateRespawnTimer() { + if (!this.activeSessionId || !this.respawnTimers[this.activeSessionId]) { + this.hideRespawnTimer(); + return; + } + + const timer = this.respawnTimers[this.activeSessionId]; + // Guard against invalid timer data + if (!timer.endAt || isNaN(timer.endAt)) { + this.hideRespawnTimer(); + return; + } + + const now = Date.now(); + const remaining = Math.max(0, timer.endAt - now); + + if (remaining <= 0) { + this.$('respawnTimer').textContent = 'Time up'; + delete this.respawnTimers[this.activeSessionId]; + this.hideRespawnTimer(); + return; + } + + this.$('respawnTimer').textContent = this.formatTime(remaining); + }, + + updateRespawnTokens(tokens) { + // Skip if tokens haven't changed (avoid unnecessary DOM writes) + const isObject = tokens && typeof tokens === 'object'; + const total = isObject ? tokens.total : tokens; + if (total === this._lastRespawnTokenTotal) return; + this._lastRespawnTokenTotal = total; + + const tokensEl = this.$('respawnTokens'); + const input = isObject ? (tokens.input || 0) : Math.round(total * 0.6); + const output = isObject ? (tokens.output || 0) : Math.round(total * 0.4); + + if (total > 0) { + tokensEl.style.display = ''; + const tokenStr = this.formatTokens(total); + const settings = this.loadAppSettingsFromStorage(); + const showCost = settings.showCost ?? false; + if (showCost) { + const estimatedCost = this.estimateCost(input, output); + tokensEl.textContent = `${tokenStr} tokens · $${estimatedCost.toFixed(2)}`; + } else { + tokensEl.textContent = `${tokenStr} tokens`; + } + } else { + tokensEl.style.display = 'none'; + } + + // Also update mobile CLI info bar (shows tokens on mobile) + this.updateCliInfoDisplay(); + }, + + // Update CLI info display (tokens, version, model - shown on mobile) + updateCliInfoDisplay() { + const infoBar = this.$('cliInfoBar'); + if (!infoBar) return; + + const session = this.sessions.get(this.activeSessionId); + if (!session) { + infoBar.style.display = 'none'; + return; + } + + // Build display parts - tokens first (most important on mobile) + let parts = []; + + // Add tokens if available + if (session.tokens) { + const total = typeof session.tokens === 'object' ? session.tokens.total : session.tokens; + if (total > 0) { + parts.push(`${this.formatTokens(total)} tokens`); + } + } + + // Add model (condensed) + if (session.cliModel) { + // Shorten model names for mobile: "claude-sonnet-4-20250514" -> "Sonnet 4" + let model = session.cliModel; + if (model.includes('opus')) model = 'Opus'; + else if (model.includes('sonnet')) model = 'Sonnet'; + else if (model.includes('haiku')) model = 'Haiku'; + parts.push(model); + } + + // Add version (compact format) + if (session.cliVersion) { + // Show "v2.1.27" or "v2.1.27 ↑" if update available + let versionStr = `v${session.cliVersion}`; + if (session.cliLatestVersion && session.cliLatestVersion !== session.cliVersion) { + versionStr += ' ↑'; // Arrow indicates update available + } + parts.push(versionStr); + } + + if (parts.length > 0) { + infoBar.textContent = parts.join(' · '); + infoBar.style.display = ''; + } else { + infoBar.style.display = 'none'; + } + }, + + // ═══════════════════════════════════════════════════════════════ + // Countdown Timer Display Methods + // ═══════════════════════════════════════════════════════════════ + + addActionLogEntry(sessionId, action) { + // Only keep truly interesting events - no spam + // KEEP: command (inputs), hook events, AI verdicts, plan verdicts + // SKIP: timer, timer-cancel, state changes, routine detection, step confirmations + + const interestingTypes = ['command', 'hook']; + + // Always keep commands and hooks + if (interestingTypes.includes(action.type)) { + // ok, keep it + } + // AI check: only verdicts (IDLE, WORKING) and errors, not "Spawning" + else if (action.type === 'ai-check') { + if (action.detail.includes('Spawning')) return; + } + // Plan check: only verdicts, not "Spawning" + else if (action.type === 'plan-check') { + if (action.detail.includes('Spawning')) return; + } + // Transcript: keep completion/plan detection + else if (action.type === 'transcript') { + // keep it + } + // Skip everything else (timer, timer-cancel, state, detection, step) + else { + return; + } + + if (!this.respawnActionLogs[sessionId]) { + this.respawnActionLogs[sessionId] = []; + } + this.respawnActionLogs[sessionId].unshift(action); + // Keep reasonable history + if (this.respawnActionLogs[sessionId].length > 30) { + this.respawnActionLogs[sessionId].pop(); + } + }, + + startCountdownInterval() { + if (this.timerCountdownInterval) return; + this.timerCountdownInterval = setInterval(() => { + if (this.activeSessionId && this.respawnCountdownTimers[this.activeSessionId]) { + this.updateCountdownTimerDisplay(); + } + }, 100); + }, + + stopCountdownInterval() { + if (this.timerCountdownInterval) { + clearInterval(this.timerCountdownInterval); + this.timerCountdownInterval = null; + } + }, + + updateCountdownTimerDisplay() { + const timersContainer = this.$('respawnCountdownTimers'); + const row2 = this.$('respawnStatusRow2'); + if (!timersContainer) return; + + const timers = this.respawnCountdownTimers[this.activeSessionId]; + const hasTimers = timers && Object.keys(timers).length > 0; + + if (!hasTimers) { + timersContainer.innerHTML = ''; + // Update row2 visibility + if (row2) { + const hookEl = document.getElementById('detectionHook'); + const aiCheckEl = document.getElementById('detectionAiCheck'); + const statusEl = this.$('detectionStatus'); + const hasVisibleContent = + (hookEl && hookEl.style.display !== 'none') || + (aiCheckEl && aiCheckEl.style.display !== 'none') || + (statusEl && statusEl.style.display !== 'none'); + row2.style.display = hasVisibleContent ? '' : 'none'; + } + return; + } + + // Show row2 since we have timers + if (row2) row2.style.display = ''; + + const now = Date.now(); + let html = ''; + + for (const [name, timer] of Object.entries(timers)) { + const remainingMs = Math.max(0, timer.endsAt - now); + const remainingSec = (remainingMs / 1000).toFixed(1); + const percent = Math.max(0, Math.min(100, (remainingMs / timer.totalMs) * 100)); + + // Shorter timer name display + const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase()); + + html += `
+ ${escapeHtml(displayName)} + ${remainingSec}s +
+
+
+
`; + } + + timersContainer.innerHTML = html; + }, + + updateActionLogDisplay() { + const logContainer = this.$('respawnActionLog'); + if (!logContainer) return; + + const actions = this.respawnActionLogs[this.activeSessionId]; + if (!actions || actions.length === 0) { + logContainer.innerHTML = ''; + return; + } + + let html = ''; + // Show fewer entries for compact view + for (const action of actions.slice(0, 5)) { + const time = new Date(action.timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + const isCommand = action.type === 'command'; + const extraClass = isCommand ? ' action-command' : ''; + // Compact format: time [type] detail + html += `
+ ${time} + [${action.type}] + ${escapeHtml(action.detail)} +
`; + } + + logContainer.innerHTML = html; + }, + + clearCountdownTimers(sessionId) { + delete this.respawnCountdownTimers[sessionId]; + delete this.respawnActionLogs[sessionId]; + if (sessionId === this.activeSessionId) { + this.updateCountdownTimerDisplay(); + this.updateActionLogDisplay(); + } + }, + + async stopRespawn() { + if (!this.activeSessionId) return; + try { + await this._apiPost(`/api/sessions/${this.activeSessionId}/respawn/stop`, {}); + delete this.respawnTimers[this.activeSessionId]; + this.clearCountdownTimers(this.activeSessionId); + } catch (err) { + this.showToast('Failed to stop respawn', 'error'); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Respawn Presets + // ═══════════════════════════════════════════════════════════════ + + loadRespawnPresets() { + // Custom presets: prefer server-synced cache, fall back to legacy localStorage key + const serverCache = this._serverRespawnPresets; + if (serverCache) return [...BUILTIN_RESPAWN_PRESETS, ...serverCache]; + const saved = localStorage.getItem('codeman-respawn-presets'); + const custom = saved ? JSON.parse(saved) : []; + return [...BUILTIN_RESPAWN_PRESETS, ...custom]; + }, + + saveRespawnPresets(presets) { + // Only save custom presets (not built-in) + const custom = presets.filter(p => !p.builtIn); + // Update local cache + legacy localStorage + this._serverRespawnPresets = custom; + localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom)); + // Persist to server (cross-device sync) + this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {}); + }, + + renderPresetDropdown() { + const presets = this.loadRespawnPresets(); + const builtinGroup = document.getElementById('builtinPresetsGroup'); + const customGroup = document.getElementById('customPresetsGroup'); + + if (!builtinGroup || !customGroup) return; + + // Clear and repopulate + builtinGroup.innerHTML = ''; + customGroup.innerHTML = ''; + + presets.forEach(preset => { + const option = document.createElement('option'); + option.value = preset.id; + option.textContent = preset.name; + if (preset.builtIn) { + builtinGroup.appendChild(option); + } else { + customGroup.appendChild(option); + } + }); + }, + + updatePresetDescription() { + const select = document.getElementById('respawnPresetSelect'); + const hint = document.getElementById('presetDescriptionHint'); + if (!select || !hint) return; + + const presetId = select.value; + if (!presetId) { + hint.textContent = ''; + return; + } + + const presets = this.loadRespawnPresets(); + const preset = presets.find(p => p.id === presetId); + hint.textContent = preset?.description || ''; + }, + + loadRespawnPreset() { + const select = document.getElementById('respawnPresetSelect'); + const presetId = select?.value; + if (!presetId) { + this.showToast('Please select a preset first', 'warning'); + return; + } + + const presets = this.loadRespawnPresets(); + const preset = presets.find(p => p.id === presetId); + if (!preset) return; + + // Populate form fields + document.getElementById('modalRespawnPrompt').value = preset.config.updatePrompt || ''; + document.getElementById('modalRespawnSendClear').checked = preset.config.sendClear ?? false; + document.getElementById('modalRespawnSendInit').checked = preset.config.sendInit ?? false; + document.getElementById('modalRespawnKickstart').value = preset.config.kickstartPrompt || ''; + document.getElementById('modalRespawnAutoAccept').checked = preset.config.autoAcceptPrompts ?? true; + + // Set duration if available + if (preset.durationMinutes) { + this.selectDurationPreset(String(preset.durationMinutes)); + } + + // Reset select to placeholder + select.value = ''; + document.getElementById('presetDescriptionHint').textContent = ''; + + this.showToast(`Loaded preset: ${preset.name}`, 'info'); + }, + + saveCurrentAsPreset() { + document.getElementById('savePresetModal').classList.add('active'); + document.getElementById('presetNameInput').value = ''; + document.getElementById('presetDescriptionInput').value = ''; + document.getElementById('presetNameInput').focus(); + }, + + closeSavePresetModal() { + document.getElementById('savePresetModal').classList.remove('active'); + }, + + confirmSavePreset() { + const name = document.getElementById('presetNameInput').value.trim(); + if (!name) { + this.showToast('Please enter a preset name', 'error'); + return; + } + + // Get current config from form + const updatePrompt = document.getElementById('modalRespawnPrompt').value; + const sendClear = document.getElementById('modalRespawnSendClear').checked; + const sendInit = document.getElementById('modalRespawnSendInit').checked; + const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined; + const durationMinutes = this.getSelectedDuration(); + + const newPreset = { + id: 'custom-' + Date.now(), + name, + description: document.getElementById('presetDescriptionInput').value.trim() || undefined, + config: { + idleTimeoutMs: 5000, // Default + updatePrompt, + interStepDelayMs: 3000, // Default + sendClear, + sendInit, + kickstartPrompt, + }, + durationMinutes: durationMinutes || undefined, + builtIn: false, + createdAt: Date.now(), + }; + + const presets = this.loadRespawnPresets(); + presets.push(newPreset); + this.saveRespawnPresets(presets); + this.renderPresetDropdown(); + this.closeSavePresetModal(); + this.showToast(`Saved preset: ${name}`, 'success'); + }, + + deletePreset(presetId) { + const presets = this.loadRespawnPresets(); + const preset = presets.find(p => p.id === presetId); + if (!preset || preset.builtIn) { + this.showToast('Cannot delete built-in presets', 'warning'); + return; + } + + const filtered = presets.filter(p => p.id !== presetId); + this.saveRespawnPresets(filtered); + this.renderPresetDropdown(); + this.showToast(`Deleted preset: ${preset.name}`, 'success'); + }, + + // Get respawn config from modal inputs + getModalRespawnConfig() { + const updatePrompt = document.getElementById('modalRespawnPrompt').value; + const sendClear = document.getElementById('modalRespawnSendClear').checked; + const sendInit = document.getElementById('modalRespawnSendInit').checked; + const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined; + const autoAcceptPrompts = document.getElementById('modalRespawnAutoAccept').checked; + const durationMinutes = this.getSelectedDuration(); + + // Auto-compact settings + const autoCompactEnabled = document.getElementById('modalAutoCompactEnabled').checked; + const autoCompactThreshold = parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000; + const autoCompactPrompt = document.getElementById('modalAutoCompactPrompt').value.trim() || undefined; + + // Auto-clear settings + const autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked; + const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000; + + return { + respawnConfig: { + enabled: true, // Fix: ensure enabled is set so pre-saved configs with enabled: false get overridden + updatePrompt, + sendClear, + sendInit, + kickstartPrompt, + autoAcceptPrompts, + }, + durationMinutes, + autoCompactEnabled, + autoCompactThreshold, + autoCompactPrompt, + autoClearEnabled, + autoClearThreshold + }; + }, + + async enableRespawnFromModal() { + if (!this.editingSessionId) { + this.showToast('No session selected', 'warning'); + return; + } + + const { + respawnConfig, + durationMinutes, + autoCompactEnabled, + autoCompactThreshold, + autoCompactPrompt, + autoClearEnabled, + autoClearThreshold + } = this.getModalRespawnConfig(); + + try { + // Enable respawn on the session + const res = await fetch(`/api/sessions/${this.editingSessionId}/respawn/enable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: respawnConfig, durationMinutes }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + + // Set auto-compact if enabled + if (autoCompactEnabled) { + await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, threshold: autoCompactThreshold, prompt: autoCompactPrompt }) + }); + } + + // Set auto-clear if enabled + if (autoClearEnabled) { + await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, threshold: autoClearThreshold }) + }); + } + + // Update UI + const respawnStatus = document.getElementById('sessionRespawnStatus'); + respawnStatus.classList.add('active'); + respawnStatus.querySelector('.respawn-status-text').textContent = 'WATCHING'; + document.getElementById('modalEnableRespawnBtn').style.display = 'none'; + document.getElementById('modalStopRespawnBtn').style.display = ''; + + this.showToast('Respawn enabled', 'success'); + } catch (err) { + this.showToast('Failed to enable respawn: ' + err.message, 'error'); + } + }, + + async stopRespawnFromModal() { + if (!this.editingSessionId) return; + try { + await fetch(`/api/sessions/${this.editingSessionId}/respawn/stop`, { method: 'POST' }); + delete this.respawnTimers[this.editingSessionId]; + + // Update the modal display + const respawnStatus = document.getElementById('sessionRespawnStatus'); + respawnStatus.classList.remove('active'); + respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active'; + document.getElementById('modalEnableRespawnBtn').style.display = ''; + document.getElementById('modalStopRespawnBtn').style.display = 'none'; + + this.showToast('Respawn stopped', 'success'); + } catch (err) { + this.showToast('Failed to stop respawn', 'error'); + } + }, + + closeSessionOptions() { + this.editingSessionId = null; + // Stop run summary auto-refresh if it was running + this.stopRunSummaryAutoRefresh(); + document.getElementById('sessionOptionsModal').classList.remove('active'); + + // Deactivate focus trap and restore focus + if (this.activeFocusTrap) { + this.activeFocusTrap.deactivate(); + this.activeFocusTrap = null; + } + }, + + setupColorPicker() { + const picker = document.getElementById('sessionColorPicker'); + if (!picker) return; + + picker.addEventListener('click', (e) => { + const swatch = e.target.closest('.color-swatch'); + if (!swatch || !this.editingSessionId) return; + + const color = swatch.dataset.color; + this.setSessionColor(this.editingSessionId, color); + }); + }, + + async setSessionColor(sessionId, color) { + try { + const res = await fetch(`/api/sessions/${sessionId}/color`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ color }) + }); + + if (res.ok) { + const session = this.sessions.get(sessionId); + if (session) { + session.color = color; + this.renderSessionTabs(); + } + + // Update picker UI to show selection + const picker = document.getElementById('sessionColorPicker'); + if (picker) { + picker.querySelectorAll('.color-swatch').forEach(swatch => { + swatch.classList.toggle('selected', swatch.dataset.color === color); + }); + } + } else { + this.showToast('Failed to set session color', 'error'); + } + } catch (err) { + this.showToast('Failed to set session color', 'error'); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Run Summary Modal + // ═══════════════════════════════════════════════════════════════ + + async openRunSummary(sessionId) { + // Open session options modal and switch to summary tab + this.openSessionOptions(sessionId); + this.switchOptionsTab('summary'); + + this.runSummarySessionId = sessionId; + this.runSummaryFilter = 'all'; + + // Reset filter buttons + document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === 'all'); + }); + + // Load summary data + await this.loadRunSummary(sessionId); + }, + + closeRunSummary() { + this.runSummarySessionId = null; + this.stopRunSummaryAutoRefresh(); + // Close session options modal (summary is now a tab in it) + this.closeSessionOptions(); + }, + + async refreshRunSummary() { + const sessionId = this.runSummarySessionId || this.editingSessionId; + if (!sessionId) return; + await this.loadRunSummary(sessionId); + }, + + toggleRunSummaryAutoRefresh() { + const checkbox = document.getElementById('runSummaryAutoRefresh'); + if (checkbox.checked) { + this.startRunSummaryAutoRefresh(); + } else { + this.stopRunSummaryAutoRefresh(); + } + }, + + startRunSummaryAutoRefresh() { + if (this.runSummaryAutoRefreshTimer) return; + this.runSummaryAutoRefreshTimer = setInterval(() => { + if (this.runSummarySessionId) { + this.loadRunSummary(this.runSummarySessionId); + } + }, 5000); // Refresh every 5 seconds + }, + + stopRunSummaryAutoRefresh() { + if (this.runSummaryAutoRefreshTimer) { + clearInterval(this.runSummaryAutoRefreshTimer); + this.runSummaryAutoRefreshTimer = null; + } + const checkbox = document.getElementById('runSummaryAutoRefresh'); + if (checkbox) checkbox.checked = false; + }, + + exportRunSummary(format) { + if (!this.runSummaryData) { + this.showToast('No summary data to export', 'error'); + return; + } + + const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData; + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const filename = `run-summary-${sessionName || 'session'}-${timestamp}`; + + if (format === 'json') { + const json = JSON.stringify(this.runSummaryData, null, 2); + this.downloadFile(`${filename}.json`, json, 'application/json'); + } else if (format === 'md') { + const duration = lastUpdatedAt - startedAt; + let md = `# Run Summary: ${sessionName || 'Session'}\n\n`; + md += `**Duration**: ${this.formatDuration(duration)}\n`; + md += `**Started**: ${new Date(startedAt).toLocaleString()}\n`; + md += `**Last Update**: ${new Date(lastUpdatedAt).toLocaleString()}\n\n`; + + md += `## Statistics\n\n`; + md += `| Metric | Value |\n`; + md += `|--------|-------|\n`; + md += `| Respawn Cycles | ${stats.totalRespawnCycles} |\n`; + md += `| Peak Tokens | ${this.formatTokens(stats.peakTokens)} |\n`; + md += `| Active Time | ${this.formatDuration(stats.totalTimeActiveMs)} |\n`; + md += `| Idle Time | ${this.formatDuration(stats.totalTimeIdleMs)} |\n`; + md += `| Errors | ${stats.errorCount} |\n`; + md += `| Warnings | ${stats.warningCount} |\n`; + md += `| AI Checks | ${stats.aiCheckCount} |\n`; + md += `| State Transitions | ${stats.stateTransitions} |\n\n`; + + md += `## Event Timeline\n\n`; + if (events.length === 0) { + md += `No events recorded.\n`; + } else { + md += `| Time | Type | Severity | Title | Details |\n`; + md += `|------|------|----------|-------|----------|\n`; + for (const event of events) { + const time = new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false }); + const details = event.details ? event.details.replace(/\|/g, '\\|') : '-'; + md += `| ${time} | ${event.type} | ${event.severity} | ${event.title} | ${details} |\n`; + } + } + + this.downloadFile(`${filename}.md`, md, 'text/markdown'); + } + + this.showToast(`Exported as ${format.toUpperCase()}`, 'success'); + }, + + downloadFile(filename, content, mimeType) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + + async loadRunSummary(sessionId) { + const timeline = document.getElementById('runSummaryTimeline'); + timeline.innerHTML = '

Loading summary...

'; + + try { + const response = await fetch(`/api/sessions/${sessionId}/run-summary`); + const data = await response.json(); + + if (!data.success) { + timeline.innerHTML = `

Failed to load summary: ${escapeHtml(data.error)}

`; + return; + } + + this.runSummaryData = data.summary; + this.renderRunSummary(); + } catch (err) { + console.error('Failed to load run summary:', err); + timeline.innerHTML = '

Failed to load summary

'; + } + }, + + renderRunSummary() { + if (!this.runSummaryData) return; + + const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData; + + // Update session info + const duration = lastUpdatedAt - startedAt; + document.getElementById('runSummarySessionInfo').textContent = + `${sessionName || 'Session'} - ${this.formatDuration(duration)} total`; + + // Filter and render events + const filteredEvents = this.filterRunSummaryEvents(events); + this.renderRunSummaryTimeline(filteredEvents); + }, + + filterRunSummaryEvents(events) { + if (this.runSummaryFilter === 'all') return events; + + return events.filter(event => { + switch (this.runSummaryFilter) { + case 'errors': return event.severity === 'error'; + case 'warnings': return event.severity === 'warning' || event.severity === 'error'; + case 'respawn': return event.type.startsWith('respawn_') || event.type === 'state_stuck'; + case 'idle': return event.type === 'idle_detected' || event.type === 'working_detected'; + default: return true; + } + }); + }, + + filterRunSummary(filter) { + this.runSummaryFilter = filter; + + // Update active state on buttons + document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + + this.renderRunSummary(); + }, + + renderRunSummaryTimeline(events) { + const timeline = document.getElementById('runSummaryTimeline'); + + if (!events || events.length === 0) { + timeline.innerHTML = '

No events recorded yet

'; + return; + } + + // Reverse to show most recent first + const reversedEvents = [...events].reverse(); + + const html = reversedEvents.map(event => { + const time = new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false }); + const severityClass = `event-${event.severity}`; + const icon = this.getEventIcon(event.type, event.severity); + + return ` +
+
${icon}
+
+
+ ${escapeHtml(event.title)} + ${time} +
+ ${event.details ? `
${escapeHtml(event.details)}
` : ''} +
+
+ `; + }).join(''); + + timeline.innerHTML = html; + }, + + getEventIcon(type, severity) { + if (severity === 'error') return '❌'; // Red X + if (severity === 'warning') return '⚠'; // Warning triangle + if (severity === 'success') return '✔'; // Checkmark + + switch (type) { + case 'session_started': return '🚀'; // Rocket + case 'session_stopped': return '🛑'; // Stop sign + case 'respawn_cycle_started': return '🔄'; // Cycle + case 'respawn_cycle_completed': return '✅'; // Green check + case 'respawn_state_change': return '➡'; // Arrow + case 'token_milestone': return '💰'; // Money bag + case 'idle_detected': return '💤'; // Zzz + case 'working_detected': return '💻'; // Laptop + case 'ai_check_result': return '🤖'; // Robot + case 'hook_event': return '🔔'; // Bell + default: return '•'; // Bullet + } + }, + + + formatDuration(ms) { + if (!ms || ms < 0) return '0s'; + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; + }, + + saveSessionOptions() { + // Session options are applied immediately via individual controls + // This just closes the modal + this.closeSessionOptions(); + }, +}); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js new file mode 100644 index 00000000..18d5b6a5 --- /dev/null +++ b/src/web/public/session-ui.js @@ -0,0 +1,1224 @@ +/** + * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode), + * session options modal (per-session settings, color picker, rename), + * session options tabs (Ralph config tab), case settings (CRUD, links), + * create case modal, and mobile case picker. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class, this.sessions, this.cases, this.activeSessionId) + * @dependency constants.js (escapeHtml) + * @dependency mobile-handlers.js (MobileDetection) + * @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js + */ + +Object.assign(CodemanApp.prototype, { + // ═══════════════════════════════════════════════════════════════ + // Quick Start + // ═══════════════════════════════════════════════════════════════ + + async loadQuickStartCases(selectCaseName = null, settingsPromise = null) { + try { + // Load settings to get lastUsedCase (reuse shared promise if provided) + let lastUsedCase = null; + try { + const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); + if (settings) { + lastUsedCase = settings.lastUsedCase || null; + } + } catch { + // Ignore settings load errors + } + + const res = await fetch('/api/cases'); + const cases = await res.json(); + this.cases = cases; + console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase); + + const select = document.getElementById('quickStartCase'); + + // Build options - existing cases first, then testcase as fallback if not present + let options = ''; + const hasTestcase = cases.some(c => c.name === 'testcase'); + const isMobile = MobileDetection.getDeviceType() === 'mobile'; + const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile + + cases.forEach(c => { + const displayName = c.name.length > maxNameLength + ? c.name.substring(0, maxNameLength) + '…' + : c.name; + options += ``; + }); + + // Add testcase option if it doesn't exist (will be created on first run) + if (!hasTestcase) { + options = `` + options; + } + + select.innerHTML = options; + console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200)); + + // If a specific case was requested, select it + if (selectCaseName) { + select.value = selectCaseName; + this.updateDirDisplayForCase(selectCaseName); + this.updateMobileCaseLabel(selectCaseName); + } else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) { + // Use lastUsedCase if available and exists + select.value = lastUsedCase; + this.updateDirDisplayForCase(lastUsedCase); + this.updateMobileCaseLabel(lastUsedCase); + } else if (cases.length > 0) { + // Fallback to testcase or first case + const firstCase = cases.find(c => c.name === 'testcase') || cases[0]; + select.value = firstCase.name; + this.updateDirDisplayForCase(firstCase.name); + this.updateMobileCaseLabel(firstCase.name); + } else { + // No cases exist yet - show the default case name as directory + select.value = 'testcase'; + document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase'; + this.updateMobileCaseLabel('testcase'); + } + + // Only add event listener once (on first load) + if (!select.dataset.listenerAdded) { + select.addEventListener('change', () => { + this.updateDirDisplayForCase(select.value); + this.saveLastUsedCase(select.value); + this.updateMobileCaseLabel(select.value); + }); + select.dataset.listenerAdded = 'true'; + } + } catch (err) { + console.error('Failed to load cases:', err); + } + }, + + async updateDirDisplayForCase(caseName) { + try { + const res = await fetch(`/api/cases/${caseName}`); + const data = await res.json(); + if (data.path) { + document.getElementById('dirDisplay').textContent = data.path; + document.getElementById('dirInput').value = data.path; + } + } catch (err) { + document.getElementById('dirDisplay').textContent = caseName; + } + }, + + async saveLastUsedCase(caseName) { + try { + // Get current settings + const res = await fetch('/api/settings'); + const settings = res.ok ? await res.json() : {}; + // Update lastUsedCase + settings.lastUsedCase = caseName; + // Save back + await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + } catch (err) { + console.error('Failed to save last used case:', err); + } + }, + + async quickStart() { + return this.run(); + }, + + /** Run using the selected mode (Claude Code or OpenCode) */ + async run() { + const mode = this._runMode || 'claude'; + if (mode === 'opencode') { + return this.runOpenCode(); + } + return this.runClaude(); + }, + + /** Get/set the run mode, persisted in localStorage */ + get runMode() { return this._runMode || 'claude'; }, + + setRunMode(mode) { + this._runMode = mode; + try { localStorage.setItem('codeman_runMode', mode); } catch {} + this._applyRunMode(); + // Sync to server for cross-device persistence + this._apiPut('/api/settings', { runMode: mode }).catch(() => {}); + // Close menu + document.getElementById('runModeMenu')?.classList.remove('active'); + }, + + toggleRunModeMenu(e) { + e?.stopPropagation(); + const menu = document.getElementById('runModeMenu'); + if (!menu) return; + menu.classList.toggle('active'); + // Update selected state + menu.querySelectorAll('.run-mode-option').forEach(btn => { + btn.classList.toggle('selected', btn.dataset.mode === this.runMode); + }); + // Load history sessions when menu opens + if (menu.classList.contains('active')) { + this._loadRunModeHistory(); + const close = (ev) => { + if (!menu.contains(ev.target)) { + menu.classList.remove('active'); + document.removeEventListener('click', close); + } + }; + setTimeout(() => document.addEventListener('click', close), 0); + } + }, + + async _loadRunModeHistory() { + const container = document.getElementById('runModeHistory'); + if (!container) return; + container.innerHTML = '
Loading...
'; + + try { + const display = await this._fetchHistorySessions(10); + if (display.length === 0) { + container.innerHTML = '
No history
'; + return; + } + + // Build items using DOM API for reliable mobile touch handling + container.replaceChildren(); + for (const s of display) { + const date = new Date(s.lastModified); + const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); + const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); + + const btn = document.createElement('button'); + btn.className = 'run-mode-option'; + btn.title = s.workingDir; + btn.dataset.sessionId = s.sessionId; + btn.dataset.workingDir = s.workingDir; + + const dirSpan = document.createElement('span'); + dirSpan.className = 'hist-dir'; + dirSpan.textContent = shortDir; + + const metaSpan = document.createElement('span'); + metaSpan.className = 'hist-meta'; + metaSpan.textContent = timeStr; + + btn.append(dirSpan, metaSpan); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.resumeHistorySession(s.sessionId, s.workingDir); + }); + container.appendChild(btn); + } + } catch (err) { + container.innerHTML = '
Failed to load
'; + } + }, + + _applyRunMode() { + const mode = this.runMode; + const runBtn = document.getElementById('runBtn'); + const gearBtn = runBtn?.nextElementSibling; + const label = document.getElementById('runBtnLabel'); + if (runBtn) { + runBtn.className = `btn-toolbar btn-run mode-${mode}`; + } + if (gearBtn) { + gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; + } + if (label) { + label.textContent = mode === 'opencode' ? 'Run OC' : 'Run'; + } + }, + + _initRunMode() { + try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; } + this._applyRunMode(); + }, + + // Tab count stepper functions + incrementTabCount() { + const input = document.getElementById('tabCount'); + const current = parseInt(input.value) || 1; + input.value = Math.min(20, current + 1); + }, + + decrementTabCount() { + const input = document.getElementById('tabCount'); + const current = parseInt(input.value) || 1; + input.value = Math.max(1, current - 1); + }, + + // Shell count stepper functions + incrementShellCount() { + const input = document.getElementById('shellCount'); + const current = parseInt(input.value) || 1; + input.value = Math.min(20, current + 1); + }, + + decrementShellCount() { + const input = document.getElementById('shellCount'); + const current = parseInt(input.value) || 1; + input.value = Math.max(1, current - 1); + }, + + async runClaude() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1)); + + this.terminal.clear(); + this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`); + this.terminal.writeln(''); + + try { + // Get case path first + const caseRes = await fetch(`/api/cases/${caseName}`); + let caseData = await caseRes.json(); + + // Create the case if it doesn't exist + if (!caseData.path) { + const createCaseRes = await fetch('/api/cases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: caseName, description: '' }) + }); + const createCaseData = await createCaseRes.json(); + if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case'); + // Use the newly created case data (API returns { success, case: { name, path } }) + caseData = createCaseData.case; + } + + const workingDir = caseData.path; + if (!workingDir) throw new Error('Case path not found'); + let firstSessionId = null; + + // Find the highest existing w-number for THIS case to avoid duplicates + let startNumber = 1; + for (const [, session] of this.sessions) { + const match = session.name && session.name.match(/^w(\d+)-(.+)$/); + if (match && match[2] === caseName) { + const num = parseInt(match[1]); + if (num >= startNumber) { + startNumber = num + 1; + } + } + } + + // Get global Ralph tracker setting + const ralphEnabled = this.isRalphTrackerEnabledByDefault(); + + // Create all sessions in parallel for speed + const sessionNames = []; + for (let i = 0; i < tabCount; i++) { + sessionNames.push(`w${startNumber + i}-${caseName}`); + } + + // Build env overrides from global + case settings (case overrides global) + const caseSettings = this.getCaseSettings(caseName); + const globalSettings = this.loadAppSettingsFromStorage(); + const envOverrides = {}; + if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) { + envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; + } + const hasEnvOverrides = Object.keys(envOverrides).length > 0; + + // Step 1: Create all sessions in parallel + this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`); + const createPromises = sessionNames.map(name => + fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) }) + }).then(r => r.json()) + ); + const createResults = await Promise.all(createPromises); + + // Collect created session IDs + const sessionIds = []; + for (const result of createResults) { + if (!result.success) throw new Error(result.error); + sessionIds.push(result.session.id); + } + firstSessionId = sessionIds[0]; + + // Step 2: Configure Ralph for all sessions in parallel + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/ralph-config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled }) + }) + )); + + // Step 3: Start all sessions in parallel (biggest speedup) + this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`); + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/interactive`, { method: 'POST' }) + )); + + this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`); + + // Auto-switch to the new session using selectSession (does proper refresh) + if (firstSessionId) { + await this.selectSession(firstSessionId); + this.loadQuickStartCases(); + } + + this.terminal.focus(); + } catch (err) { + this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + } + }, + + /** Send Ctrl+C to the active session to stop the current operation. + * Requires double-tap: first tap turns button amber, second tap within 2s sends Ctrl+C. */ + stopClaude() { + if (!this.activeSessionId) return; + const btn = document.querySelector('.btn-toolbar.btn-stop'); + if (!btn) return; + + if (this._stopConfirmTimer) { + // Second tap — send Ctrl+C + clearTimeout(this._stopConfirmTimer); + this._stopConfirmTimer = null; + btn.innerHTML = btn.dataset.origHtml; + delete btn.dataset.origHtml; + btn.classList.remove('confirming'); + fetch(`/api/sessions/${this.activeSessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: '\x03' }) + }); + } else { + // First tap — enter confirm state + btn.dataset.origHtml = btn.innerHTML; + btn.textContent = 'Tap again'; + btn.classList.add('confirming'); + this._stopConfirmTimer = setTimeout(() => { + this._stopConfirmTimer = null; + if (btn.dataset.origHtml) { + btn.innerHTML = btn.dataset.origHtml; + delete btn.dataset.origHtml; + } + btn.classList.remove('confirming'); + }, 2000); + } + }, + + async runShell() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); + + this.terminal.clear(); + this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`); + this.terminal.writeln(''); + + try { + // Get the case path + const caseRes = await fetch(`/api/cases/${caseName}`); + const caseData = await caseRes.json(); + const workingDir = caseData.path; + if (!workingDir) throw new Error('Case path not found'); + + // Find the highest existing s-number for THIS case to avoid duplicates + let startNumber = 1; + for (const [, session] of this.sessions) { + const match = session.name && session.name.match(/^s(\d+)-(.+)$/); + if (match && match[2] === caseName) { + const num = parseInt(match[1]); + if (num >= startNumber) { + startNumber = num + 1; + } + } + } + + // Create all shell sessions in parallel + const sessionNames = []; + for (let i = 0; i < shellCount; i++) { + sessionNames.push(`s${startNumber + i}-${caseName}`); + } + + // Step 1: Create all sessions in parallel + const createPromises = sessionNames.map(name => + fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir, mode: 'shell', name }) + }).then(r => r.json()) + ); + const createResults = await Promise.all(createPromises); + + const sessionIds = []; + for (const result of createResults) { + if (!result.success) throw new Error(result.error); + sessionIds.push(result.session.id); + } + + // Step 2: Start all shells in parallel + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/shell`, { method: 'POST' }) + )); + + // Step 3: Resize all in parallel (with minimum dimension enforcement) + const dims = this.getTerminalDimensions(); + if (dims) { + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dims) + }) + )); + } + + // Switch to first session + if (sessionIds.length > 0) { + this.activeSessionId = sessionIds[0]; + await this.selectSession(sessionIds[0]); + } + + this.terminal.focus(); + } catch (err) { + this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + } + }, + + async runOpenCode() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + + this.terminal.clear(); + this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); + this.terminal.writeln(''); + + try { + // Check if OpenCode is available + const statusRes = await fetch('/api/opencode/status'); + const status = await statusRes.json(); + if (!status.available) { + this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); + this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m'); + return; + } + + // Quick-start with opencode mode (auto-allow tools by default) + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseName, + mode: 'opencode', + openCodeConfig: { autoAllowTools: true }, + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start OpenCode'); + + // Switch to the new session (don't pre-set activeSessionId — selectSession + // early-returns when IDs match, skipping buffer load and sendResize) + if (data.sessionId) { + await this.selectSession(data.sessionId); + } + + this.terminal.focus(); + } catch (err) { + this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Session Options Modal + // ═══════════════════════════════════════════════════════════════ + + openSessionOptions(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return; + + this.editingSessionId = sessionId; + + // Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only) + this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn'); + + // Update respawn status display and buttons + const respawnStatus = document.getElementById('sessionRespawnStatus'); + const enableBtn = document.getElementById('modalEnableRespawnBtn'); + const stopBtn = document.getElementById('modalStopRespawnBtn'); + + if (this.respawnStatus[sessionId]) { + respawnStatus.classList.add('active'); + respawnStatus.querySelector('.respawn-status-text').textContent = + this.respawnStatus[sessionId].state || 'Active'; + enableBtn.style.display = 'none'; + stopBtn.style.display = ''; + } else { + respawnStatus.classList.remove('active'); + respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active'; + enableBtn.style.display = ''; + stopBtn.style.display = 'none'; + } + + // Only show respawn section for claude mode sessions with a running process + const respawnSection = document.getElementById('sessionRespawnSection'); + if (session.mode === 'claude' && session.pid) { + respawnSection.style.display = ''; + } else { + respawnSection.style.display = 'none'; + } + + // Hide Claude-specific options for OpenCode sessions + const isOpenCode = session.mode === 'opencode'; + const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); + claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; }); + + // Reset duration presets to default (unlimited) + this.selectDurationPreset(''); + + // Populate respawn config from saved state + this.loadSavedRespawnConfig(sessionId); + + // Populate auto-compact/clear from session state + document.getElementById('modalAutoCompactEnabled').checked = session.autoCompactEnabled ?? false; + document.getElementById('modalAutoCompactThreshold').value = session.autoCompactThreshold ?? 110000; + document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? ''; + document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false; + document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000; + document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; + document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; + + // Populate session name input + document.getElementById('modalSessionName').value = session.name || ''; + + // Initialize color picker with current session color + const currentColor = session.color || 'default'; + const colorPicker = document.getElementById('sessionColorPicker'); + colorPicker?.querySelectorAll('.color-swatch').forEach(s => { + s.classList.toggle('selected', s.dataset.color === currentColor); + }); + + // Initialize respawn preset dropdown + this.renderPresetDropdown(); + document.getElementById('respawnPresetSelect').value = ''; + document.getElementById('presetDescriptionHint').textContent = ''; + + // Hide Ralph/Todo tab and Respawn tab for opencode sessions (not supported) + const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]'); + const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]'); + if (isOpenCode) { + if (ralphTabBtn) ralphTabBtn.style.display = 'none'; + if (respawnTabBtn) respawnTabBtn.style.display = 'none'; + // Default to Context tab for opencode sessions since Respawn is hidden + this.switchOptionsTab('context'); + } else { + if (ralphTabBtn) ralphTabBtn.style.display = ''; + if (respawnTabBtn) respawnTabBtn.style.display = ''; + } + + // Populate Ralph Wiggum form with current session values (skip for opencode) + if (!isOpenCode) { + const ralphState = this.ralphStates.get(sessionId); + this.populateRalphForm({ + enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false, + completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '', + maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0, + }); + } + + const modal = document.getElementById('sessionOptionsModal'); + modal.classList.add('active'); + + // Activate focus trap + this.activeFocusTrap = new FocusTrap(modal); + this.activeFocusTrap.activate(); + }, + + async saveSessionName() { + if (!this.editingSessionId) return; + const name = document.getElementById('modalSessionName').value.trim(); + try { + await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name }); + } catch (err) { + this.showToast('Failed to save session name: ' + err.message, 'error'); + } + }, + + async autoSaveAutoCompact() { + if (!this.editingSessionId) return; + try { + await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, { + enabled: document.getElementById('modalAutoCompactEnabled').checked, + threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000, + prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined + }); + } catch { /* silent */ } + }, + + async autoSaveAutoClear() { + if (!this.editingSessionId) return; + try { + await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, { + enabled: document.getElementById('modalAutoClearEnabled').checked, + threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000 + }); + } catch { /* silent */ } + }, + + async toggleSessionImageWatcher() { + if (!this.editingSessionId) return; + const enabled = document.getElementById('modalImageWatcherEnabled').checked; + try { + await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled }); + // Update local session state + const session = this.sessions.get(this.editingSessionId); + if (session) { + session.imageWatcherEnabled = enabled; + } + this.showToast(`Image watcher ${enabled ? 'enabled' : 'disabled'}`, 'success'); + } catch (err) { + this.showToast('Failed to toggle image watcher', 'error'); + } + }, + + async toggleFlickerFilter() { + if (!this.editingSessionId) return; + const enabled = document.getElementById('modalFlickerFilterEnabled').checked; + try { + await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled }); + // Update local session state + const session = this.sessions.get(this.editingSessionId); + if (session) { + session.flickerFilterEnabled = enabled; + } + this.showToast(`Flicker filter ${enabled ? 'enabled' : 'disabled'}`, 'success'); + } catch (err) { + this.showToast('Failed to toggle flicker filter', 'error'); + } + }, + + async autoSaveRespawnConfig() { + if (!this.editingSessionId) return; + const config = { + updatePrompt: document.getElementById('modalRespawnPrompt').value, + sendClear: document.getElementById('modalRespawnSendClear').checked, + sendInit: document.getElementById('modalRespawnSendInit').checked, + kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined, + autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked, + }; + try { + await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config); + } catch { + // Silent save - don't interrupt user + } + }, + + async loadSavedRespawnConfig(sessionId) { + try { + const res = await fetch(`/api/sessions/${sessionId}/respawn/config`); + const data = await res.json(); + if (data.success && data.config) { + const c = data.config; + document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md'; + document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true; + document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true; + document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || ''; + document.getElementById('modalRespawnAutoAccept').checked = c.autoAcceptPrompts ?? true; + // Restore duration if set + if (c.durationMinutes) { + const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`); + if (presetBtn) { + this.selectDurationPreset(String(c.durationMinutes)); + } else { + this.selectDurationPreset('custom'); + document.getElementById('modalRespawnDuration').value = c.durationMinutes; + } + } + } + } catch { + // Ignore - use defaults + } + }, + + // Handle duration preset selection + selectDurationPreset(value) { + // Remove active from all buttons + document.querySelectorAll('.duration-preset-btn').forEach(btn => btn.classList.remove('active')); + + // Find and activate the clicked button + const btn = document.querySelector(`.duration-preset-btn[data-minutes="${value}"]`); + if (btn) btn.classList.add('active'); + + // Show/hide custom input + const customInput = document.querySelector('.duration-custom-input'); + const durationInput = document.getElementById('modalRespawnDuration'); + + if (value === 'custom') { + customInput.classList.add('visible'); + durationInput.focus(); + } else { + customInput.classList.remove('visible'); + durationInput.value = ''; // Clear custom value when using preset + } + }, + + // Get selected duration from preset buttons or custom input + getSelectedDuration() { + const customInput = document.querySelector('.duration-custom-input'); + const durationInput = document.getElementById('modalRespawnDuration'); + + if (customInput.classList.contains('visible')) { + // Custom mode - use input value + return durationInput.value ? parseInt(durationInput.value) : null; + } else { + // Preset mode - get from active button + const activeBtn = document.querySelector('.duration-preset-btn.active'); + const minutes = activeBtn?.dataset.minutes; + return minutes ? parseInt(minutes) : null; + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Session Options Modal Tabs + // ═══════════════════════════════════════════════════════════════ + + switchOptionsTab(tabName) { + // Toggle active class on tab buttons + document.querySelectorAll('#sessionOptionsModal .modal-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + + // Toggle hidden class on tab content + document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn'); + document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context'); + document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph'); + document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary'); + + // Load run summary data when switching to summary tab + if (tabName === 'summary' && this.editingSessionId) { + this.loadRunSummary(this.editingSessionId); + } + }, + + getRalphConfig() { + return { + enabled: document.getElementById('modalRalphEnabled').checked, + completionPhrase: document.getElementById('modalRalphPhrase').value.trim(), + maxIterations: parseInt(document.getElementById('modalRalphMaxIterations').value) || 0, + maxTodos: parseInt(document.getElementById('modalRalphMaxTodos').value) || 50, + todoExpirationMinutes: parseInt(document.getElementById('modalRalphTodoExpiration').value) || 60 + }; + }, + + populateRalphForm(config) { + document.getElementById('modalRalphEnabled').checked = config?.enabled ?? false; + document.getElementById('modalRalphPhrase').value = config?.completionPhrase || ''; + document.getElementById('modalRalphMaxIterations').value = config?.maxIterations || 0; + document.getElementById('modalRalphMaxTodos').value = config?.maxTodos || 50; + document.getElementById('modalRalphTodoExpiration').value = config?.todoExpirationMinutes || 60; + }, + + async saveRalphConfig() { + if (!this.editingSessionId) { + this.showToast('No session selected', 'warning'); + return; + } + + const config = this.getRalphConfig(); + + // If user is enabling Ralph, clear from closed set + if (config.enabled) { + this.ralphClosedSessions.delete(this.editingSessionId); + } + + try { + const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + + this.showToast('Ralph config saved', 'success'); + } catch (err) { + this.showToast('Failed to save Ralph config: ' + err.message, 'error'); + } + }, + + // Inline rename on right-click + startInlineRename(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return; + + const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`); + if (!tabName) return; + + const currentName = this.getSessionName(session); + const input = document.createElement('input'); + input.type = 'text'; + input.value = session.name || ''; + input.placeholder = currentName; + input.className = 'tab-rename-input'; + input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;'; + + const originalContent = tabName.textContent; + tabName.textContent = ''; + tabName.appendChild(input); + input.focus(); + input.select(); + + const finishRename = async () => { + const newName = input.value.trim(); + tabName.textContent = newName || originalContent; + + if (newName && newName !== session.name) { + try { + await fetch(`/api/sessions/${sessionId}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName }) + }); + } catch (err) { + tabName.textContent = originalContent; + this.showToast('Failed to rename', 'error'); + } + } + }; + + input.addEventListener('blur', finishRename); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } else if (e.key === 'Escape') { + input.value = ''; + input.blur(); + } + }); + }, + + + // ═══════════════════════════════════════════════════════════════ + // Case Settings + // ═══════════════════════════════════════════════════════════════ + + toggleCaseSettings() { + const popover = document.getElementById('caseSettingsPopover'); + if (popover.classList.contains('hidden')) { + // Load settings for current case + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + const settings = this.getCaseSettings(caseName); + document.getElementById('caseAgentTeams').checked = settings.agentTeams; + popover.classList.remove('hidden'); + + // Close on outside click (one-shot listener) + const closeHandler = (e) => { + if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) { + popover.classList.add('hidden'); + document.removeEventListener('click', closeHandler); + } + }; + // Defer to avoid catching the current click + setTimeout(() => document.addEventListener('click', closeHandler), 0); + } else { + popover.classList.add('hidden'); + } + }, + + getCaseSettings(caseName) { + try { + const stored = localStorage.getItem('caseSettings_' + caseName); + if (stored) return JSON.parse(stored); + } catch { /* ignore */ } + return { agentTeams: false }; + }, + + saveCaseSettings(caseName, settings) { + localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings)); + }, + + onCaseSettingChanged() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + const settings = this.getCaseSettings(caseName); + settings.agentTeams = document.getElementById('caseAgentTeams').checked; + this.saveCaseSettings(caseName, settings); + // Sync mobile checkbox + const mobileCheckbox = document.getElementById('caseAgentTeamsMobile'); + if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams; + }, + + toggleCaseSettingsMobile() { + const popover = document.getElementById('caseSettingsPopoverMobile'); + if (popover.classList.contains('hidden')) { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + const settings = this.getCaseSettings(caseName); + document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams; + popover.classList.remove('hidden'); + + const closeHandler = (e) => { + if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) { + popover.classList.add('hidden'); + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 0); + } else { + popover.classList.add('hidden'); + } + }, + + onCaseSettingChangedMobile() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + const settings = this.getCaseSettings(caseName); + settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked; + this.saveCaseSettings(caseName, settings); + // Sync desktop checkbox + const desktopCheckbox = document.getElementById('caseAgentTeams'); + if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams; + }, + + // ═══════════════════════════════════════════════════════════════ + // Create Case Modal + // ═══════════════════════════════════════════════════════════════ + + showCreateCaseModal() { + document.getElementById('newCaseName').value = ''; + document.getElementById('newCaseDescription').value = ''; + document.getElementById('linkCaseName').value = ''; + document.getElementById('linkCasePath').value = ''; + // Reset to first tab + this.caseModalTab = 'case-create'; + this.switchCaseModalTab('case-create'); + // Wire up tab buttons + const modal = document.getElementById('createCaseModal'); + modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { + btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab); + }); + // Scroll-into-view on focus for mobile keyboard visibility + modal.querySelectorAll('input[type="text"]').forEach(input => { + if (!input._mobileScrollWired) { + input._mobileScrollWired = true; + input.addEventListener('focus', () => { + if (window.innerWidth <= 430) { + setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300); + } + }); + } + }); + modal.classList.add('active'); + document.getElementById('newCaseName').focus(); + }, + + switchCaseModalTab(tabName) { + this.caseModalTab = tabName; + const modal = document.getElementById('createCaseModal'); + // Toggle active class on tab buttons + modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + // Toggle hidden class on tab content + modal.querySelectorAll('.modal-tab-content').forEach(content => { + content.classList.toggle('hidden', content.id !== tabName); + }); + // Update submit button text + const submitBtn = document.getElementById('caseModalSubmit'); + submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link'; + // Focus appropriate input + if (tabName === 'case-create') { + document.getElementById('newCaseName').focus(); + } else { + document.getElementById('linkCaseName').focus(); + } + }, + + closeCreateCaseModal() { + document.getElementById('createCaseModal').classList.remove('active'); + }, + + async submitCaseModal() { + const btn = document.getElementById('caseModalSubmit'); + const originalText = btn.textContent; + btn.classList.add('loading'); + btn.textContent = this.caseModalTab === 'case-create' ? 'Creating...' : 'Linking...'; + try { + if (this.caseModalTab === 'case-create') { + await this.createCase(); + } else { + await this.linkCase(); + } + } finally { + btn.classList.remove('loading'); + btn.textContent = originalText; + } + }, + + async createCase() { + const name = document.getElementById('newCaseName').value.trim(); + const description = document.getElementById('newCaseDescription').value.trim(); + + if (!name) { + this.showToast('Please enter a case name', 'error'); + return; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error'); + return; + } + + try { + const res = await fetch('/api/cases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + + const data = await res.json(); + if (data.success) { + this.closeCreateCaseModal(); + this.showToast(`Case "${name}" created`, 'success'); + // Reload cases and select the new one + await this.loadQuickStartCases(name); + // Save as last used case + await this.saveLastUsedCase(name); + } else { + this.showToast(data.error || 'Failed to create case', 'error'); + } + } catch (err) { + console.error('Failed to create case:', err); + this.showToast('Failed to create case: ' + err.message, 'error'); + } + }, + + async linkCase() { + const name = document.getElementById('linkCaseName').value.trim(); + const path = document.getElementById('linkCasePath').value.trim(); + + if (!name) { + this.showToast('Please enter a case name', 'error'); + return; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error'); + return; + } + + if (!path) { + this.showToast('Please enter a folder path', 'error'); + return; + } + + try { + const res = await fetch('/api/cases/link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, path }) + }); + + const data = await res.json(); + if (data.success) { + this.closeCreateCaseModal(); + this.showToast(`Case "${name}" linked to ${path}`, 'success'); + // Reload cases and select the new one + await this.loadQuickStartCases(name); + // Save as last used case + await this.saveLastUsedCase(name); + } else { + this.showToast(data.error || 'Failed to link case', 'error'); + } + } catch (err) { + console.error('Failed to link case:', err); + this.showToast('Failed to link case: ' + err.message, 'error'); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Mobile Case Picker + // ═══════════════════════════════════════════════════════════════ + + showMobileCasePicker() { + const modal = document.getElementById('mobileCasePickerModal'); + const listContainer = document.getElementById('mobileCaseList'); + const select = document.getElementById('quickStartCase'); + const currentCase = select.value; + + // Build case list HTML + let html = ''; + const cases = this.cases || []; + + // Add testcase if not in list + const hasTestcase = cases.some(c => c.name === 'testcase'); + const allCases = hasTestcase ? cases : [{ name: 'testcase' }, ...cases]; + + for (const c of allCases) { + const isSelected = c.name === currentCase; + html += ` + + `; + } + + listContainer.innerHTML = html; + modal.classList.add('active'); + }, + + closeMobileCasePicker() { + document.getElementById('mobileCasePickerModal').classList.remove('active'); + }, + + selectMobileCase(caseName) { + // Update the desktop select (source of truth) + const select = document.getElementById('quickStartCase'); + select.value = caseName; + + // Update mobile button label + this.updateMobileCaseLabel(caseName); + + // Update directory display + this.updateDirDisplayForCase(caseName); + + // Save as last used + this.saveLastUsedCase(caseName); + + // Close the picker + this.closeMobileCasePicker(); + + this.showToast(`Selected: ${caseName}`, 'success'); + }, + + updateMobileCaseLabel(caseName) { + const label = document.getElementById('mobileCaseName'); + if (label) { + // Let CSS handle truncation via text-overflow: ellipsis + label.textContent = caseName; + } + }, + + showCreateCaseFromMobile() { + // Close mobile picker first + this.closeMobileCasePicker(); + // Open the create case modal with slide-up animation + this.showCreateCaseModal(); + const modal = document.getElementById('createCaseModal'); + modal.classList.add('from-mobile'); + // Remove animation class after it plays + setTimeout(() => modal.classList.remove('from-mobile'), 300); + }, +}); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js new file mode 100644 index 00000000..943b9ad6 --- /dev/null +++ b/src/web/public/settings-ui.js @@ -0,0 +1,1935 @@ +/** + * @fileoverview App settings modal, visibility settings (header/panel/device-specific defaults), + * web push notifications, session lifecycle log (JSONL viewer), tunnel/QR management, + * persistent parent associations, and help modal. + * Includes 13 SSE handlers for hooks and tunnel events. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class, this.notificationManager, this._tunnelUrl) + * @dependency constants.js (escapeHtml) + * @dependency keyboard-accessory.js (FocusTrap) + * @loadorder 10 of 15 — loaded after ralph-panel.js, before panels-ui.js + */ + +Object.assign(CodemanApp.prototype, { + // Hooks (Claude Code hook events) + _onHookIdlePrompt(data) { + const session = this.sessions.get(data.sessionId); + // Always track pending hook - alert will show when switching away from session + if (data.sessionId) { + this.setPendingHook(data.sessionId, 'idle_prompt'); + } + this.notificationManager?.notify({ + urgency: 'warning', + category: 'hook-idle', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Waiting for Input', + message: data.message || 'Claude is idle and waiting for a prompt', + }); + }, + + _onHookPermissionPrompt(data) { + const session = this.sessions.get(data.sessionId); + // Always track pending hook - action alerts need user interaction to clear + if (data.sessionId) { + this.setPendingHook(data.sessionId, 'permission_prompt'); + } + const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : ''; + this.notificationManager?.notify({ + urgency: 'critical', + category: 'hook-permission', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Permission Required', + message: toolInfo || 'Claude needs tool approval to continue', + }); + }, + + _onHookElicitationDialog(data) { + const session = this.sessions.get(data.sessionId); + // Always track pending hook - action alerts need user interaction to clear + if (data.sessionId) { + this.setPendingHook(data.sessionId, 'elicitation_dialog'); + } + this.notificationManager?.notify({ + urgency: 'critical', + category: 'hook-elicitation', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Question Asked', + message: data.question || 'Claude is asking a question and waiting for your answer', + }); + }, + + _onHookStop(data) { + const session = this.sessions.get(data.sessionId); + // Clear all pending hooks when Claude finishes responding + if (data.sessionId) { + this.clearPendingHooks(data.sessionId); + } + this.notificationManager?.notify({ + urgency: 'info', + category: 'hook-stop', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Response Complete', + message: data.reason || 'Claude has finished responding', + }); + }, + + _onHookTeammateIdle(data) { + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'warning', + category: 'hook-teammate-idle', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Teammate Idle', + message: `A teammate is idle in ${session?.name || data.sessionId}`, + }); + }, + + _onHookTaskCompleted(data) { + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'info', + category: 'hook-task-completed', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Task Completed', + message: `A team task completed in ${session?.name || data.sessionId}`, + }); + }, + + + // Tunnel + _onTunnelStarted(data) { + console.log('[Tunnel] Started:', data.url); + this._tunnelUrl = data.url; + this._dismissTunnelConnecting(); + this._updateTunnelUrlDisplay(data.url); + this._updateTunnelIndicator(true); + const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible'); + if (welcomeVisible) { + // On welcome screen: QR appears inline, expanded first + this._updateWelcomeTunnelBtn(true, data.url, true); + this.showToast(`Tunnel active`, 'success'); + } else { + // Not on welcome screen: popup QR overlay + this._updateWelcomeTunnelBtn(true, data.url); + this.showToast(`Tunnel active: ${data.url}`, 'success'); + this.showTunnelQR(); + } + }, + + _onTunnelStopped() { + console.log('[Tunnel] Stopped'); + this._tunnelUrl = null; + this._dismissTunnelConnecting(); + this._updateTunnelUrlDisplay(null); + this._updateWelcomeTunnelBtn(false); + this._updateTunnelIndicator(false); + this.closeTunnelPanel(); + this.closeTunnelQR(); + }, + + _onTunnelProgress(data) { + console.log('[Tunnel] Progress:', data.message); + const toast = document.getElementById('tunnelConnectingToast'); + if (toast) { + toast.innerHTML = ` ${data.message}`; + } + // Also update button text if on welcome screen + const btn = document.getElementById('welcomeTunnelBtn'); + if (btn?.classList.contains('connecting')) { + btn.innerHTML = ` ${data.message}`; + } + }, + + _onTunnelError(data) { + console.warn('[Tunnel] Error:', data.message); + this._dismissTunnelConnecting(); + this.showToast(`Tunnel error: ${data.message}`, 'error'); + const btn = document.getElementById('welcomeTunnelBtn'); + if (btn) { btn.disabled = false; btn.classList.remove('connecting'); } + }, + + _onTunnelQrRotated(data) { + if (data.svg) { + const container = document.getElementById('tunnelQrContainer'); + if (container) container.innerHTML = data.svg; + const welcomeInner = document.getElementById('welcomeQrInner'); + if (welcomeInner) welcomeInner.innerHTML = data.svg; + } else { + this._refreshTunnelQrFromApi(); + } + this._resetQrCountdown(); + }, + + _onTunnelQrRegenerated(data) { + if (data.svg) { + const container = document.getElementById('tunnelQrContainer'); + if (container) container.innerHTML = data.svg; + const welcomeInner = document.getElementById('welcomeQrInner'); + if (welcomeInner) welcomeInner.innerHTML = data.svg; + } else { + this._refreshTunnelQrFromApi(); + } + this._resetQrCountdown(); + }, + + _onTunnelQrAuthUsed(data) { + const ua = data.ua || 'Unknown device'; + const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser'; + this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', { + duration: 10000, + action: { label: 'Revoke All', onClick: () => { + fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + .then(() => this.showToast('All sessions revoked', 'success')) + .catch(() => this.showToast('Failed to revoke sessions', 'error')); + }}, + }); + }, + + + // ═══════════════════════════════════════════════════════════════ + // Web Push + // ═══════════════════════════════════════════════════════════════ + + registerServiceWorker() { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.register('/sw.js').then((reg) => { + this._swRegistration = reg; + // Listen for messages from service worker (notification clicks) + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.type === 'notification-click') { + const { sessionId } = event.data; + if (sessionId && this.sessions.has(sessionId)) { + this.selectSession(sessionId); + } + window.focus(); + } + }); + // Check if already subscribed + reg.pushManager.getSubscription().then((sub) => { + if (sub) { + this._pushSubscription = sub; + this._updatePushUI(true); + } + }); + }).catch(() => { + // Service worker registration failed (likely not HTTPS) + }); + }, + + async subscribeToPush() { + if (!this._swRegistration) { + this.showToast('Service worker not available. HTTPS or localhost required.', 'error'); + return; + } + try { + // Get VAPID public key from server + const keyData = await this._apiJson('/api/push/vapid-key'); + if (!keyData?.success) throw new Error('Failed to get VAPID key'); + + const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey); + const subscription = await this._swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); + + // Send subscription to server + const subJson = subscription.toJSON(); + const data = await this._apiJson('/api/push/subscribe', { + method: 'POST', + body: { + endpoint: subJson.endpoint, + keys: subJson.keys, + userAgent: navigator.userAgent, + pushPreferences: this._buildPushPreferences(), + }, + }); + if (!data?.success) throw new Error('Failed to register subscription'); + + this._pushSubscription = subscription; + this._pushSubscriptionId = data.data.id; + localStorage.setItem('codeman-push-subscription-id', data.data.id); + this._updatePushUI(true); + this.showToast('Push notifications enabled', 'success'); + } catch (err) { + this.showToast('Push subscription failed: ' + (err.message || err), 'error'); + } + }, + + async unsubscribeFromPush() { + try { + if (this._pushSubscription) { + await this._pushSubscription.unsubscribe(); + } + const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); + if (subId) { + await fetch(`/api/push/subscribe/${subId}`, { method: 'DELETE' }).catch(() => {}); + } + this._pushSubscription = null; + this._pushSubscriptionId = null; + localStorage.removeItem('codeman-push-subscription-id'); + this._updatePushUI(false); + this.showToast('Push notifications disabled', 'success'); + } catch (err) { + this.showToast('Failed to unsubscribe: ' + (err.message || err), 'error'); + } + }, + + async togglePushSubscription() { + if (this._pushSubscription) { + await this.unsubscribeFromPush(); + } else { + await this.subscribeToPush(); + } + }, + + /** Sync push preferences to server */ + async _syncPushPreferences() { + const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); + if (!subId) return; + try { + await fetch(`/api/push/subscribe/${subId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pushPreferences: this._buildPushPreferences() }), + }); + } catch { + // Silently fail — prefs saved locally, will sync on next subscribe + } + }, + + /** Build push preferences object from current event type checkboxes */ + _buildPushPreferences() { + const prefs = {}; + const eventMap = { + 'hook:permission_prompt': 'eventPermissionPush', + 'hook:elicitation_dialog': 'eventQuestionPush', + 'hook:idle_prompt': 'eventIdlePush', + 'hook:stop': 'eventStopPush', + 'respawn:blocked': 'eventRespawnPush', + 'session:ralphCompletionDetected': 'eventRalphPush', + }; + for (const [event, checkboxId] of Object.entries(eventMap)) { + const el = document.getElementById(checkboxId); + prefs[event] = el ? el.checked : true; + } + // session:error always receives push (no per-event toggle, always critical) + prefs['session:error'] = true; + return prefs; + }, + + _updatePushUI(subscribed) { + const btn = document.getElementById('pushSubscribeBtn'); + const status = document.getElementById('pushSubscriptionStatus'); + if (btn) btn.textContent = subscribed ? 'Unsubscribe' : 'Subscribe'; + if (status) { + status.textContent = subscribed ? 'active' : 'off'; + status.classList.remove('granted', 'denied'); + if (subscribed) status.classList.add('granted'); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // App Settings Modal + // ═══════════════════════════════════════════════════════════════ + + openAppSettings() { + // Load current settings + const settings = this.loadAppSettingsFromStorage(); + document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || ''; + document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || ''; + // Use device-aware defaults for display settings (mobile has different defaults) + const defaults = this.getDefaultSettings(); + document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? defaults.ralphTrackerEnabled ?? false; + // Header visibility settings + document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false; + document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true; + document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true; + document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false; + document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true; + document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true; + document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; + document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; + document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; + document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; + document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; + document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; + document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; + this.loadTunnelStatus(); + document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); + document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; + // Claude CLI settings + const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); + const allowedToolsRow = document.getElementById('allowedToolsRow'); + claudeModeSelect.value = settings.claudeMode || 'dangerously-skip-permissions'; + document.getElementById('appSettingsAllowedTools').value = settings.allowedTools || ''; + allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; + // Toggle allowed tools row visibility based on mode selection + claudeModeSelect.onchange = () => { + allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; + }; + // Claude Permissions settings + document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; + // CPU Priority settings + const niceSettings = settings.nice || {}; + document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; + document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10; + // Model configuration (loaded from server) + this.loadModelConfigForSettings(); + // Notification settings + const notifPrefs = this.notificationManager?.preferences || {}; + document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true; + document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false; + document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false; + document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000); + document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical; + document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning; + document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo; + // Push notification settings + document.getElementById('appSettingsPushEnabled').checked = !!this._pushSubscription; + this._updatePushUI(!!this._pushSubscription); + // Per-event-type preferences + const eventTypes = notifPrefs.eventTypes || {}; + // Permission prompts + const permPref = eventTypes.permission_prompt || {}; + document.getElementById('eventPermissionEnabled').checked = permPref.enabled ?? true; + document.getElementById('eventPermissionBrowser').checked = permPref.browser ?? true; + document.getElementById('eventPermissionPush').checked = permPref.push ?? false; + document.getElementById('eventPermissionAudio').checked = permPref.audio ?? true; + // Questions (elicitation_dialog) + const questionPref = eventTypes.elicitation_dialog || {}; + document.getElementById('eventQuestionEnabled').checked = questionPref.enabled ?? true; + document.getElementById('eventQuestionBrowser').checked = questionPref.browser ?? true; + document.getElementById('eventQuestionPush').checked = questionPref.push ?? false; + document.getElementById('eventQuestionAudio').checked = questionPref.audio ?? true; + // Session idle (idle_prompt) + const idlePref = eventTypes.idle_prompt || {}; + document.getElementById('eventIdleEnabled').checked = idlePref.enabled ?? true; + document.getElementById('eventIdleBrowser').checked = idlePref.browser ?? true; + document.getElementById('eventIdlePush').checked = idlePref.push ?? false; + document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false; + // Response complete (stop) + const stopPref = eventTypes.stop || {}; + document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true; + document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false; + document.getElementById('eventStopPush').checked = stopPref.push ?? false; + document.getElementById('eventStopAudio').checked = stopPref.audio ?? false; + // Respawn cycles + const respawnPref = eventTypes.respawn_cycle || {}; + document.getElementById('eventRespawnEnabled').checked = respawnPref.enabled ?? true; + document.getElementById('eventRespawnBrowser').checked = respawnPref.browser ?? false; + document.getElementById('eventRespawnPush').checked = respawnPref.push ?? false; + document.getElementById('eventRespawnAudio').checked = respawnPref.audio ?? false; + // Task complete (ralph_complete) + const ralphPref = eventTypes.ralph_complete || {}; + document.getElementById('eventRalphEnabled').checked = ralphPref.enabled ?? true; + document.getElementById('eventRalphBrowser').checked = ralphPref.browser ?? true; + document.getElementById('eventRalphPush').checked = ralphPref.push ?? false; + document.getElementById('eventRalphAudio').checked = ralphPref.audio ?? true; + // Subagent activity (subagent_spawn and subagent_complete) + const subagentPref = eventTypes.subagent_spawn || {}; + document.getElementById('eventSubagentEnabled').checked = subagentPref.enabled ?? false; + document.getElementById('eventSubagentBrowser').checked = subagentPref.browser ?? false; + document.getElementById('eventSubagentPush').checked = subagentPref.push ?? false; + document.getElementById('eventSubagentAudio').checked = subagentPref.audio ?? false; + // Update permission status display (compact format for new grid layout) + const permStatus = document.getElementById('notifPermissionStatus'); + if (permStatus && typeof Notification !== 'undefined') { + const perm = Notification.permission; + permStatus.textContent = perm === 'granted' ? '\u2713' : perm === 'denied' ? '\u2717' : '?'; + permStatus.classList.remove('granted', 'denied'); + if (perm === 'granted') permStatus.classList.add('granted'); + else if (perm === 'denied') permStatus.classList.add('denied'); + } + // Voice settings (loaded from localStorage only) + const voiceCfg = VoiceInput._getDeepgramConfig(); + document.getElementById('voiceDeepgramKey').value = voiceCfg.apiKey || ''; + document.getElementById('voiceLanguage').value = voiceCfg.language || 'en-US'; + document.getElementById('voiceKeyterms').value = voiceCfg.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'; + document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct'; + // Reset key visibility to hidden + const keyInput = document.getElementById('voiceDeepgramKey'); + keyInput.type = 'password'; + document.getElementById('voiceKeyToggleBtn').textContent = 'Show'; + // Update provider status + const providerName = VoiceInput.getActiveProviderName(); + const providerEl = document.getElementById('voiceProviderStatus'); + providerEl.textContent = providerName; + providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : ''); + + // Reset to first tab and wire up tab switching + this.switchSettingsTab('settings-display'); + const modal = document.getElementById('appSettingsModal'); + modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { + btn.onclick = () => this.switchSettingsTab(btn.dataset.tab); + }); + modal.classList.add('active'); + + // Activate focus trap + this.activeFocusTrap = new FocusTrap(modal); + this.activeFocusTrap.activate(); + }, + + switchSettingsTab(tabName) { + const modal = document.getElementById('appSettingsModal'); + // Toggle active class on tab buttons + modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + // Toggle hidden class on tab content + modal.querySelectorAll('.modal-tab-content').forEach(content => { + content.classList.toggle('hidden', content.id !== tabName); + }); + }, + + closeAppSettings() { + document.getElementById('appSettingsModal').classList.remove('active'); + + // Deactivate focus trap and restore focus + if (this.activeFocusTrap) { + this.activeFocusTrap.deactivate(); + this.activeFocusTrap = null; + } + }, + + async loadTunnelStatus() { + try { + const res = await fetch('/api/tunnel/status'); + const status = await res.json(); + const active = status.running && status.url; + this._tunnelUrl = active ? status.url : null; + this._updateTunnelUrlDisplay(this._tunnelUrl); + this._updateWelcomeTunnelBtn(!!active, this._tunnelUrl); + this._updateTunnelIndicator(!!active); + } catch { + this._tunnelUrl = null; + this._updateTunnelUrlDisplay(null); + this._updateWelcomeTunnelBtn(false); + this._updateTunnelIndicator(false); + } + }, + + _updateTunnelUrlDisplay(url) { + const row = document.getElementById('tunnelUrlRow'); + const display = document.getElementById('tunnelUrlDisplay'); + if (!row || !display) return; + if (url) { + row.style.display = ''; + display.textContent = url; + display.onclick = () => { + navigator.clipboard.writeText(url).then(() => { + this.showToast('Tunnel URL copied', 'success'); + }); + }; + } else { + row.style.display = 'none'; + display.textContent = ''; + display.onclick = null; + } + // Upload URL row + const uploadRow = document.getElementById('tunnelUploadUrlRow'); + const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay'); + if (!uploadRow || !uploadDisplay) return; + if (url) { + const uploadUrl = url + '/upload.html'; + uploadRow.style.display = ''; + uploadDisplay.textContent = uploadUrl; + uploadDisplay.onclick = () => { + navigator.clipboard.writeText(uploadUrl).then(() => { + this.showToast('Upload URL copied', 'success'); + }); + }; + } else { + uploadRow.style.display = 'none'; + uploadDisplay.textContent = ''; + uploadDisplay.onclick = null; + } + }, + + showTunnelQR() { + // Close existing popup if open + this.closeTunnelQR(); + + const overlay = document.createElement('div'); + overlay.id = 'tunnelQrOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:pointer'; + overlay.onclick = (e) => { if (e.target === overlay) this.closeTunnelQR(); }; + + const card = document.createElement('div'); + card.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center;max-width:340px;width:90vw;box-shadow:var(--shadow-lg);cursor:default'; + + card.innerHTML = ` +
Scan to connect
+
+
Loading...
+
+
+ + `; + + overlay.appendChild(card); + document.body.appendChild(overlay); + + // Fetch QR SVG from server + fetch('/api/tunnel/qr') + .then(res => { + if (!res.ok) throw new Error('Tunnel not running'); + return res.json(); + }) + .then(data => { + const container = document.getElementById('tunnelQrContainer'); + if (container && data.svg) container.innerHTML = data.svg; + // Show auth badge, countdown, and regenerate button when auth is enabled + if (data.authEnabled) { + const badge = document.createElement('div'); + badge.id = 'tunnelQrBadge'; + badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)'; + badge.textContent = 'Single-use auth \u00b7 expires in 60s'; + const regenBtn = document.createElement('button'); + regenBtn.textContent = 'Regenerate QR'; + regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px'; + regenBtn.onclick = () => { + fetch('/api/tunnel/qr/regenerate', { method: 'POST' }) + .then(() => this.showToast('QR code regenerated', 'success')) + .catch(() => this.showToast('Failed to regenerate QR', 'error')); + }; + const card = container.parentElement; + if (card) { + card.appendChild(badge); + card.appendChild(regenBtn); + } + this._resetQrCountdown(); + } + }) + .catch(() => { + const container = document.getElementById('tunnelQrContainer'); + if (container) container.innerHTML = '
Tunnel not active
'; + }); + + // Fetch URL for display + fetch('/api/tunnel/status') + .then(r => r.json()) + .then(status => { + const urlEl = document.getElementById('tunnelQrUrl'); + if (urlEl && status.url) { + urlEl.textContent = status.url; + urlEl.onclick = () => { + navigator.clipboard.writeText(status.url).then(() => { + this.showToast('Tunnel URL copied', 'success'); + }); + }; + } + }) + .catch(() => {}); + + // Close on Escape + this._tunnelQrEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelQR(); }; + document.addEventListener('keydown', this._tunnelQrEscHandler); + }, + + closeTunnelQR() { + const overlay = document.getElementById('tunnelQrOverlay'); + if (overlay) overlay.remove(); + if (this._tunnelQrEscHandler) { + document.removeEventListener('keydown', this._tunnelQrEscHandler); + this._tunnelQrEscHandler = null; + } + this._clearQrCountdown(); + }, + + /** Fallback: fetch QR SVG from API when SSE payload lacks it */ + _refreshTunnelQrFromApi() { + fetch('/api/tunnel/qr') + .then(res => res.ok ? res.json() : null) + .then(data => { + if (!data?.svg) return; + const container = document.getElementById('tunnelQrContainer'); + if (container) container.innerHTML = data.svg; + const welcomeInner = document.getElementById('welcomeQrInner'); + if (welcomeInner) welcomeInner.innerHTML = data.svg; + }) + .catch(() => {}); + }, + + /** Start or reset the 60s countdown on the QR badge */ + _resetQrCountdown() { + this._clearQrCountdown(); + this._qrCountdownSec = 60; + this._updateQrCountdownText(); + this._qrCountdownTimer = setInterval(() => { + this._qrCountdownSec--; + if (this._qrCountdownSec <= 0) { + this._clearQrCountdown(); + return; + } + this._updateQrCountdownText(); + }, 1000); + }, + + _updateQrCountdownText() { + const badge = document.getElementById('tunnelQrBadge'); + if (badge) { + badge.textContent = `Single-use auth \u00b7 expires in ${this._qrCountdownSec}s`; + } + }, + + _clearQrCountdown() { + if (this._qrCountdownTimer) { + clearInterval(this._qrCountdownTimer); + this._qrCountdownTimer = null; + } + }, + + async toggleTunnelFromWelcome() { + const btn = document.getElementById('welcomeTunnelBtn'); + if (!btn) return; + const isActive = btn.classList.contains('active'); + btn.disabled = true; + try { + const newEnabled = !isActive; + await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tunnelEnabled: newEnabled }), + }); + if (newEnabled) { + this._showTunnelConnecting(); + // Poll tunnel status as fallback in case SSE event is missed + this._pollTunnelStatus(); + } else { + this._dismissTunnelConnecting(); + this.showToast('Tunnel stopped', 'info'); + this._updateWelcomeTunnelBtn(false); + btn.disabled = false; + } + } catch (err) { + this._dismissTunnelConnecting(); + this.showToast('Failed to toggle tunnel', 'error'); + btn.disabled = false; + } + }, + + _showTunnelConnecting() { + // Remove any existing connecting toast first (without resetting button state) + const oldToast = document.getElementById('tunnelConnectingToast'); + if (oldToast) { + oldToast.remove(); + } + const btn = document.getElementById('welcomeTunnelBtn'); + if (btn) { + btn.classList.add('connecting'); + btn.innerHTML = ` + + Connecting...`; + } + // Persistent toast with spinner + const toast = document.createElement('div'); + toast.className = 'toast toast-info show'; + toast.id = 'tunnelConnectingToast'; + toast.innerHTML = ' Cloudflare Tunnel connecting...'; + toast.style.pointerEvents = 'auto'; + if (!this._toastContainer) { + this._toastContainer = document.querySelector('.toast-container'); + if (!this._toastContainer) { + this._toastContainer = document.createElement('div'); + this._toastContainer.className = 'toast-container'; + document.body.appendChild(this._toastContainer); + } + } + this._toastContainer.appendChild(toast); + }, + + _dismissTunnelConnecting() { + clearTimeout(this._tunnelPollTimer); + this._tunnelPollTimer = null; + const toast = document.getElementById('tunnelConnectingToast'); + if (toast) { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 200); + } + const btn = document.getElementById('welcomeTunnelBtn'); + if (btn) btn.classList.remove('connecting'); + }, + + _pollTunnelStatus(attempt = 0) { + if (attempt > 15) return; // give up after ~30s + this._tunnelPollTimer = setTimeout(async () => { + try { + const res = await fetch('/api/tunnel/status'); + const status = await res.json(); + if (status.running && status.url) { + // Tunnel is up — update UI + this._dismissTunnelConnecting(); + this._updateTunnelUrlDisplay(status.url); + const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible'); + if (welcomeVisible) { + this._updateWelcomeTunnelBtn(true, status.url, true); + this.showToast('Tunnel active', 'success'); + } else { + this._updateWelcomeTunnelBtn(true, status.url); + this.showToast(`Tunnel active: ${status.url}`, 'success'); + this.showTunnelQR(); + } + return; + } + } catch { /* ignore */ } + this._pollTunnelStatus(attempt + 1); + }, 2000); + }, + + _updateWelcomeTunnelBtn(active, url, firstAppear = false) { + const btn = document.getElementById('welcomeTunnelBtn'); + if (btn) { + btn.disabled = false; + if (active) { + btn.classList.remove('connecting'); + btn.classList.add('active'); + btn.innerHTML = ` + + Tunnel Active`; + } else { + btn.classList.remove('active', 'connecting'); + btn.innerHTML = ` + + Cloudflare Tunnel`; + } + } + // Update welcome QR code + const qrWrap = document.getElementById('welcomeQr'); + const qrInner = document.getElementById('welcomeQrInner'); + const qrUrl = document.getElementById('welcomeQrUrl'); + if (!qrWrap || !qrInner) return; + if (active) { + qrWrap.classList.add('visible'); + // First appear: start expanded, auto-shrink after 8s + if (firstAppear) { + qrWrap.classList.add('expanded'); + clearTimeout(this._welcomeQrShrinkTimer); + this._welcomeQrShrinkTimer = setTimeout(() => { + qrWrap.classList.remove('expanded'); + }, 8000); + } + if (url) { + qrUrl.textContent = url; + qrUrl.title = 'Click QR to enlarge'; + } + fetch('/api/tunnel/qr') + .then(r => { if (!r.ok) throw new Error(); return r.json(); }) + .then(data => { if (data.svg) qrInner.innerHTML = data.svg; }) + .catch(() => { qrInner.innerHTML = '
QR unavailable
'; }); + } else { + clearTimeout(this._welcomeQrShrinkTimer); + qrWrap.classList.remove('visible', 'expanded'); + qrInner.innerHTML = ''; + if (qrUrl) qrUrl.textContent = ''; + } + }, + + toggleWelcomeQrSize() { + const qrWrap = document.getElementById('welcomeQr'); + if (qrWrap) { + clearTimeout(this._welcomeQrShrinkTimer); + qrWrap.classList.toggle('expanded'); + } + }, + + // ═══════════════════════════════════════════════════════════════ + // Tunnel Header Indicator & Panel (desktop only) + // ═══════════════════════════════════════════════════════════════ + + _updateTunnelIndicator(active) { + if (MobileDetection.getDeviceType() === 'mobile') return; + const indicator = document.getElementById('tunnelIndicator'); + if (!indicator) return; + indicator.style.display = active ? 'flex' : 'none'; + indicator.classList.remove('connecting'); + }, + + toggleTunnelPanel() { + const existing = document.getElementById('tunnelPanel'); + if (existing) { + this.closeTunnelPanel(); + return; + } + this._openTunnelPanel(); + }, + + async _openTunnelPanel() { + const panel = document.createElement('div'); + panel.className = 'tunnel-panel'; + panel.id = 'tunnelPanel'; + panel.innerHTML = ` +
+

+ + Cloudflare Tunnel + Loading... +

+
+
+
Loading...
+
+ `; + document.body.appendChild(panel); + + // Close on outside click + this._tunnelPanelClickHandler = (e) => { + if (!panel.contains(e.target) && e.target.id !== 'tunnelIndicator' && !e.target.closest('.tunnel-indicator')) { + this.closeTunnelPanel(); + } + }; + setTimeout(() => document.addEventListener('click', this._tunnelPanelClickHandler), 0); + + // Close on Escape + this._tunnelPanelEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelPanel(); }; + document.addEventListener('keydown', this._tunnelPanelEscHandler); + + // Fetch tunnel info + try { + const res = await fetch('/api/tunnel/info'); + const info = await res.json(); + this._renderTunnelPanel(info); + } catch { + const body = document.getElementById('tunnelPanelBody'); + if (body) body.innerHTML = '
Failed to load tunnel info
'; + } + }, + + _renderTunnelPanel(info) { + const statusEl = document.getElementById('tunnelPanelStatus'); + const body = document.getElementById('tunnelPanelBody'); + if (!statusEl || !body) return; + + statusEl.textContent = info.running ? 'Connected' : 'Offline'; + statusEl.className = 'tunnel-panel-status' + (info.running ? '' : ' offline'); + + let html = ''; + + // URL section + if (info.url) { + html += ` +
+
URL
+
${escapeHtml(info.url)}
+
`; + } + + // Clients section + html += ` +
+
Connections
+
+ Remote Clients + ${info.sseClients} +
`; + + if (info.authEnabled) { + html += ` +
+ Auth Sessions + ${info.authSessions.length} +
`; + } + html += '
'; + + // Auth sessions detail + if (info.authEnabled && info.authSessions.length > 0) { + html += '
Authenticated Devices
'; + for (const s of info.authSessions) { + const ua = s.ua || 'Unknown'; + const browser = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser'; + const ago = this._formatTimeAgo(s.createdAt); + html += ` +
+ + ${escapeHtml(browser)} · ${escapeHtml(s.ip)} · ${ago} + ${s.method} +
`; + } + html += '
'; + } + + // Actions + html += '
'; + if (info.running) { + html += ` + + `; + } else { + html += ``; + } + html += '
'; + + // Revoke all sessions button + if (info.authEnabled && info.authSessions.length > 0) { + html += ` +
+ +
`; + } + + body.innerHTML = html; + + // Bind URL copy handler + const urlEl = document.getElementById('tunnelPanelUrl'); + if (urlEl) { + urlEl.onclick = () => { + navigator.clipboard.writeText(info.url).then(() => this.showToast('Tunnel URL copied', 'success')); + }; + } + }, + + _formatTimeAgo(timestamp) { + const diff = Date.now() - timestamp; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; + }, + + async _tunnelPanelToggle(enable) { + try { + await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tunnelEnabled: enable }), + }); + if (enable) { + this._updateTunnelIndicator(false); + const indicator = document.getElementById('tunnelIndicator'); + if (indicator) { + indicator.style.display = 'flex'; + indicator.classList.add('connecting'); + } + this.showToast('Tunnel starting...', 'info'); + this._showTunnelConnecting(); + this._pollTunnelStatus(); + } else { + this.showToast('Tunnel stopped', 'info'); + } + this.closeTunnelPanel(); + } catch { + this.showToast('Failed to toggle tunnel', 'error'); + } + }, + + async _tunnelPanelRevokeAll() { + try { + await fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + this.showToast('All sessions revoked', 'success'); + // Refresh panel + const res = await fetch('/api/tunnel/info'); + const info = await res.json(); + this._renderTunnelPanel(info); + } catch { + this.showToast('Failed to revoke sessions', 'error'); + } + }, + + closeTunnelPanel() { + const panel = document.getElementById('tunnelPanel'); + if (panel) panel.remove(); + if (this._tunnelPanelClickHandler) { + document.removeEventListener('click', this._tunnelPanelClickHandler); + this._tunnelPanelClickHandler = null; + } + if (this._tunnelPanelEscHandler) { + document.removeEventListener('keydown', this._tunnelPanelEscHandler); + this._tunnelPanelEscHandler = null; + } + }, + + toggleDeepgramKeyVisibility() { + const input = document.getElementById('voiceDeepgramKey'); + const btn = document.getElementById('voiceKeyToggleBtn'); + if (input.type === 'password') { + input.type = 'text'; + btn.textContent = 'Hide'; + } else { + input.type = 'password'; + btn.textContent = 'Show'; + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Session Lifecycle Log + // ═══════════════════════════════════════════════════════════════ + + openLifecycleLog() { + const win = document.getElementById('lifecycleWindow'); + win.style.display = 'block'; + // Reset transform so it appears centered initially + if (!win._dragInitialized) { + win.style.left = '50%'; + win.style.transform = 'translateX(-50%)'; + this._initLifecycleDrag(win); + win._dragInitialized = true; + } + this.loadLifecycleLog(); + }, + + closeLifecycleLog() { + document.getElementById('lifecycleWindow').style.display = 'none'; + }, + + _initLifecycleDrag(win) { + const header = document.getElementById('lifecycleWindowHeader'); + let isDragging = false; + let startX, startY, startLeft, startTop; + + header.addEventListener('mousedown', (e) => { + if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; + isDragging = true; + // Clear transform so left/top work in absolute pixels + const rect = win.getBoundingClientRect(); + win.style.transform = 'none'; + win.style.left = rect.left + 'px'; + win.style.top = rect.top + 'px'; + startX = e.clientX; + startY = e.clientY; + startLeft = rect.left; + startTop = rect.top; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + win.style.left = (startLeft + e.clientX - startX) + 'px'; + win.style.top = (startTop + e.clientY - startY) + 'px'; + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); + }, + + async loadLifecycleLog() { + const eventFilter = document.getElementById('lifecycleFilterEvent').value; + const sessionFilter = document.getElementById('lifecycleFilterSession').value.trim(); + const params = new URLSearchParams(); + if (eventFilter) params.set('event', eventFilter); + if (sessionFilter) params.set('sessionId', sessionFilter); + params.set('limit', '300'); + + try { + const res = await fetch(`/api/session-lifecycle?${params}`); + const data = await res.json(); + const tbody = document.getElementById('lifecycleTableBody'); + const empty = document.getElementById('lifecycleEmpty'); + + if (!data.entries || data.entries.length === 0) { + tbody.innerHTML = ''; + empty.style.display = ''; + return; + } + empty.style.display = 'none'; + + const eventColors = { + created: '#4ade80', started: '#4ade80', recovered: '#4ade80', + exit: '#fbbf24', mux_died: '#f87171', deleted: '#f87171', stale_cleaned: '#f87171', + server_started: '#666', server_stopped: '#666', + }; + + tbody.innerHTML = data.entries.map(e => { + const time = new Date(e.ts).toLocaleString(); + const color = eventColors[e.event] || '#888'; + const name = e.name || (e.sessionId === '*' ? '—' : this.getShortId(e.sessionId)); + const extra = []; + if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`); + if (e.mode) extra.push(e.mode); + return ` + ${time} + ${e.event} + ${name} + ${e.reason || ''} + ${extra.join(', ')} + `; + }).join(''); + } catch (err) { + console.error('Failed to load lifecycle log:', err); + } + }, + + async saveAppSettings() { + const settings = { + defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(), + defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(), + ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked, + // Header visibility settings + showFontControls: document.getElementById('appSettingsShowFontControls').checked, + showSystemStats: document.getElementById('appSettingsShowSystemStats').checked, + showTokenCount: document.getElementById('appSettingsShowTokenCount').checked, + showCost: document.getElementById('appSettingsShowCost').checked, + showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked, + showMonitor: document.getElementById('appSettingsShowMonitor').checked, + showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked, + showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked, + showSubagents: document.getElementById('appSettingsShowSubagents').checked, + subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, + subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, + imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, + tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, + localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, + tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, + // Claude CLI settings + claudeMode: document.getElementById('appSettingsClaudeMode').value, + allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), + // Claude Permissions settings + agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, + // CPU Priority settings + nice: { + enabled: document.getElementById('appSettingsNiceEnabled').checked, + niceValue: parseInt(document.getElementById('appSettingsNiceValue').value) || 10, + }, + }; + + // Save to localStorage + this.saveAppSettingsToStorage(settings); + this._updateLocalEchoState(); + + // Save voice settings to localStorage + include in server payload for cross-device sync + const voiceSettings = { + apiKey: document.getElementById('voiceDeepgramKey').value.trim(), + language: document.getElementById('voiceLanguage').value, + keyterms: document.getElementById('voiceKeyterms').value.trim(), + insertMode: document.getElementById('voiceInsertMode').value, + }; + VoiceInput._saveDeepgramConfig(voiceSettings); + + // Save notification preferences separately + const notifPrefsToSave = { + enabled: document.getElementById('appSettingsNotifEnabled').checked, + browserNotifications: document.getElementById('appSettingsNotifBrowser').checked, + audioAlerts: document.getElementById('appSettingsNotifAudio').checked, + stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000, + muteCritical: !document.getElementById('appSettingsNotifCritical').checked, + muteWarning: !document.getElementById('appSettingsNotifWarning').checked, + muteInfo: !document.getElementById('appSettingsNotifInfo').checked, + // Per-event-type preferences + eventTypes: { + permission_prompt: { + enabled: document.getElementById('eventPermissionEnabled').checked, + browser: document.getElementById('eventPermissionBrowser').checked, + push: document.getElementById('eventPermissionPush').checked, + audio: document.getElementById('eventPermissionAudio').checked, + }, + elicitation_dialog: { + enabled: document.getElementById('eventQuestionEnabled').checked, + browser: document.getElementById('eventQuestionBrowser').checked, + push: document.getElementById('eventQuestionPush').checked, + audio: document.getElementById('eventQuestionAudio').checked, + }, + idle_prompt: { + enabled: document.getElementById('eventIdleEnabled').checked, + browser: document.getElementById('eventIdleBrowser').checked, + push: document.getElementById('eventIdlePush').checked, + audio: document.getElementById('eventIdleAudio').checked, + }, + stop: { + enabled: document.getElementById('eventStopEnabled').checked, + browser: document.getElementById('eventStopBrowser').checked, + push: document.getElementById('eventStopPush').checked, + audio: document.getElementById('eventStopAudio').checked, + }, + session_error: { + enabled: true, + browser: this.notificationManager?.preferences?.eventTypes?.session_error?.browser ?? true, + push: this.notificationManager?.preferences?.eventTypes?.session_error?.push ?? false, + audio: false, + }, + respawn_cycle: { + enabled: document.getElementById('eventRespawnEnabled').checked, + browser: document.getElementById('eventRespawnBrowser').checked, + push: document.getElementById('eventRespawnPush').checked, + audio: document.getElementById('eventRespawnAudio').checked, + }, + token_milestone: { + enabled: true, + browser: false, + push: false, + audio: false, + }, + ralph_complete: { + enabled: document.getElementById('eventRalphEnabled').checked, + browser: document.getElementById('eventRalphBrowser').checked, + push: document.getElementById('eventRalphPush').checked, + audio: document.getElementById('eventRalphAudio').checked, + }, + subagent_spawn: { + enabled: document.getElementById('eventSubagentEnabled').checked, + browser: document.getElementById('eventSubagentBrowser').checked, + push: document.getElementById('eventSubagentPush').checked, + audio: document.getElementById('eventSubagentAudio').checked, + }, + subagent_complete: { + enabled: document.getElementById('eventSubagentEnabled').checked, + browser: document.getElementById('eventSubagentBrowser').checked, + push: document.getElementById('eventSubagentPush').checked, + audio: document.getElementById('eventSubagentAudio').checked, + }, + }, + _version: 4, + }; + if (this.notificationManager) { + this.notificationManager.preferences = notifPrefsToSave; + this.notificationManager.savePreferences(); + } + + // Sync push preferences to server + this._syncPushPreferences(); + + // Apply header visibility immediately + this.applyHeaderVisibilitySettings(); + this.applyTabWrapSettings(); + this._updateTokensImmediate(); // Re-render token display (picks up showCost change) + this.applyMonitorVisibility(); + this.renderProjectInsightsPanel(); // Re-render to apply visibility setting + this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting + + // Save to server (includes notification prefs for cross-browser persistence) + // Strip device-specific keys — localEchoEnabled is per-platform (touch default differs) + const { localEchoEnabled: _leo, ...serverSettings } = settings; + try { + await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); + + // Save model configuration separately + await this.saveModelConfigFromSettings(); + + this.showToast('Settings saved', 'success'); + + // Show tunnel-specific feedback if toggled on + if (settings.tunnelEnabled) { + this.showToast('Tunnel starting — QR code will appear when ready...', 'info'); + } + } catch (err) { + // Server save failed but localStorage succeeded + this.showToast('Settings saved locally', 'warning'); + } + + this.closeAppSettings(); + }, + + // Load model configuration from server for the settings modal + async loadModelConfigForSettings() { + try { + const res = await fetch('/api/execution/model-config'); + const data = await res.json(); + if (data.success && data.data) { + const config = data.data; + // Default model + const defaultModelEl = document.getElementById('appSettingsDefaultModel'); + if (defaultModelEl) { + defaultModelEl.value = config.defaultModel || 'opus'; + } + // Show recommendations + const showRecsEl = document.getElementById('appSettingsShowModelRecommendations'); + if (showRecsEl) { + showRecsEl.checked = config.showRecommendations ?? true; + } + // Agent type overrides + const overrides = config.agentTypeOverrides || {}; + const exploreEl = document.getElementById('appSettingsModelExplore'); + const implementEl = document.getElementById('appSettingsModelImplement'); + const testEl = document.getElementById('appSettingsModelTest'); + const reviewEl = document.getElementById('appSettingsModelReview'); + if (exploreEl) exploreEl.value = overrides.explore || ''; + if (implementEl) implementEl.value = overrides.implement || ''; + if (testEl) testEl.value = overrides.test || ''; + if (reviewEl) reviewEl.value = overrides.review || ''; + } + } catch (err) { + console.warn('Failed to load model config:', err); + } + }, + + // Save model configuration from settings modal to server + async saveModelConfigFromSettings() { + const defaultModelEl = document.getElementById('appSettingsDefaultModel'); + const showRecsEl = document.getElementById('appSettingsShowModelRecommendations'); + const exploreEl = document.getElementById('appSettingsModelExplore'); + const implementEl = document.getElementById('appSettingsModelImplement'); + const testEl = document.getElementById('appSettingsModelTest'); + const reviewEl = document.getElementById('appSettingsModelReview'); + + const agentTypeOverrides = {}; + if (exploreEl?.value) agentTypeOverrides.explore = exploreEl.value; + if (implementEl?.value) agentTypeOverrides.implement = implementEl.value; + if (testEl?.value) agentTypeOverrides.test = testEl.value; + if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value; + + const config = { + defaultModel: defaultModelEl?.value || 'opus', + showRecommendations: showRecsEl?.checked ?? true, + agentTypeOverrides, + }; + + try { + await fetch('/api/execution/model-config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + } catch (err) { + console.warn('Failed to save model config:', err); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Visibility Settings & Device-Specific Defaults + // ═══════════════════════════════════════════════════════════════ + + // Get the global Ralph tracker enabled setting + isRalphTrackerEnabledByDefault() { + const settings = this.loadAppSettingsFromStorage(); + return settings.ralphTrackerEnabled ?? false; + }, + + // Get the settings storage key based on device type (mobile vs desktop) + getSettingsStorageKey() { + const isMobile = MobileDetection.getDeviceType() === 'mobile'; + return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings'; + }, + + // Get default settings based on device type + // Note: Notification prefs are handled separately by NotificationManager + getDefaultSettings() { + const isMobile = MobileDetection.getDeviceType() === 'mobile'; + if (isMobile) { + // Mobile defaults: minimal UI for small screens + return { + // Header visibility - hide everything on mobile + showFontControls: false, + showSystemStats: false, + showTokenCount: false, + showCost: false, + // Panel visibility - hide panels on mobile (not enough space) + showMonitor: false, + showProjectInsights: false, + showFileBrowser: false, + showSubagents: false, + // Feature toggles - keep tracking on even on mobile + subagentTrackingEnabled: true, + subagentActiveTabOnly: true, // Only show subagents for active tab + imageWatcherEnabled: false, + ralphTrackerEnabled: false, + tabTwoRows: false, + }; + } + // Desktop defaults - rely on ?? operators in apply functions + // This allows desktop to have different defaults without duplication + return {}; + }, + + loadAppSettingsFromStorage() { + // Return cached settings if available (avoids synchronous localStorage + JSON.parse + // on every SSE event — critical for input responsiveness) + if (this._cachedAppSettings) return this._cachedAppSettings; + try { + const key = this.getSettingsStorageKey(); + const saved = localStorage.getItem(key); + if (saved) { + this._cachedAppSettings = JSON.parse(saved); + return this._cachedAppSettings; + } + } catch (err) { + console.error('Failed to load app settings:', err); + } + // Return device-specific defaults + this._cachedAppSettings = this.getDefaultSettings(); + return this._cachedAppSettings; + }, + + saveAppSettingsToStorage(settings) { + // Invalidate cache on save + this._cachedAppSettings = settings; + try { + const key = this.getSettingsStorageKey(); + localStorage.setItem(key, JSON.stringify(settings)); + } catch (err) { + console.error('Failed to save app settings:', err); + } + }, + + applyHeaderVisibilitySettings() { + const settings = this.loadAppSettingsFromStorage(); + const defaults = this.getDefaultSettings(); + const showFontControls = settings.showFontControls ?? defaults.showFontControls ?? false; + const showSystemStats = settings.showSystemStats ?? defaults.showSystemStats ?? true; + const showTokenCount = settings.showTokenCount ?? defaults.showTokenCount ?? true; + + const fontControlsEl = document.querySelector('.header-font-controls'); + const systemStatsEl = document.getElementById('headerSystemStats'); + const tokenCountEl = document.getElementById('headerTokens'); + + if (fontControlsEl) { + fontControlsEl.style.display = showFontControls ? '' : 'none'; + } + if (systemStatsEl) { + systemStatsEl.style.display = showSystemStats ? '' : 'none'; + } + if (tokenCountEl) { + tokenCountEl.style.display = showTokenCount ? '' : 'none'; + } + + // Hide lifecycle log button when setting is disabled + const showLifecycleLog = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true; + const lifecycleBtn = document.querySelector('.btn-lifecycle-log'); + if (lifecycleBtn) { + lifecycleBtn.style.display = showLifecycleLog ? '' : 'none'; + } + + // Hide notification bell when notifications are disabled + const notifEnabled = this.notificationManager?.preferences?.enabled ?? true; + const notifBtn = document.querySelector('.btn-notifications'); + if (notifBtn) { + notifBtn.style.display = notifEnabled ? '' : 'none'; + } + // Close the drawer if notifications got disabled while it's open + if (!notifEnabled) { + const drawer = document.getElementById('notifDrawer'); + if (drawer) drawer.classList.remove('open'); + } + }, + + applyTabWrapSettings() { + const settings = this.loadAppSettingsFromStorage(); + const defaults = this.getDefaultSettings(); + const deviceType = MobileDetection.getDeviceType(); + // Two-row tabs disabled on mobile/tablet — not enough screen space + const twoRows = deviceType === 'desktop' + ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) + : false; + const prevTallTabs = this._tallTabsEnabled; + this._tallTabsEnabled = twoRows; + const tabsEl = document.getElementById('sessionTabs'); + if (tabsEl) { + tabsEl.classList.toggle('tabs-two-rows', twoRows); + tabsEl.classList.toggle('tabs-show-folder', twoRows); + } + // Re-render tabs if folder visibility changed (folder spans are generated in JS) + if (prevTallTabs !== undefined && prevTallTabs !== twoRows) { + this._fullRenderSessionTabs(); + } + }, + + applyMonitorVisibility() { + const settings = this.loadAppSettingsFromStorage(); + const defaults = this.getDefaultSettings(); + const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true; + const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? false; + const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; + + const monitorPanel = document.getElementById('monitorPanel'); + if (monitorPanel) { + monitorPanel.style.display = showMonitor ? '' : 'none'; + if (showMonitor) { + monitorPanel.classList.add('open'); + } else { + monitorPanel.classList.remove('open'); + } + } + + const subagentsPanel = document.getElementById('subagentsPanel'); + if (subagentsPanel) { + if (showSubagents) { + subagentsPanel.classList.remove('hidden'); + } else { + subagentsPanel.classList.add('hidden'); + } + } + + // File browser panel visibility + const fileBrowserPanel = document.getElementById('fileBrowserPanel'); + if (fileBrowserPanel) { + if (showFileBrowser && this.activeSessionId) { + fileBrowserPanel.classList.add('visible'); + this.loadFileBrowser(this.activeSessionId); + // Attach drag listeners if not already attached + if (!this.fileBrowserDragListeners) { + const header = fileBrowserPanel.querySelector('.file-browser-header'); + if (header) { + // Convert right-positioned to left/top before drag so makeWindowDraggable works + const onFirstDrag = () => { + if (!fileBrowserPanel.style.left) { + const rect = fileBrowserPanel.getBoundingClientRect(); + fileBrowserPanel.style.left = `${rect.left}px`; + fileBrowserPanel.style.top = `${rect.top}px`; + fileBrowserPanel.style.right = 'auto'; + } + }; + header.addEventListener('mousedown', onFirstDrag); + header.addEventListener('touchstart', onFirstDrag, { passive: true }); + this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header); + this.fileBrowserDragListeners._onFirstDrag = onFirstDrag; + } + } + } else { + fileBrowserPanel.classList.remove('visible'); + } + } + }, + + closeMonitor() { + // Hide the monitor panel + const monitorPanel = document.getElementById('monitorPanel'); + if (monitorPanel) { + monitorPanel.classList.remove('open'); + monitorPanel.style.display = 'none'; + } + // Save the setting + const settings = this.loadAppSettingsFromStorage(); + settings.showMonitor = false; + this.saveAppSettingsToStorage(settings); + }, + + closeSubagentsPanel() { + // Hide the subagents panel + const subagentsPanel = document.getElementById('subagentsPanel'); + if (subagentsPanel) { + subagentsPanel.classList.remove('open'); + subagentsPanel.classList.add('hidden'); + } + this.subagentPanelVisible = false; + // Save the setting + const settings = this.loadAppSettingsFromStorage(); + settings.showSubagents = false; + this.saveAppSettingsToStorage(settings); + }, + + async clearAllSubagents() { + const count = this.subagents.size; + if (count === 0) { + this.showToast('No subagents to clear', 'info'); + return; + } + + if (!confirm(`Clear all ${count} tracked subagent(s)? This removes them from the UI but does not affect running processes.`)) { + return; + } + + try { + const res = await fetch('/api/subagents', { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + // Clear local state + this.subagents.clear(); + this.subagentActivity.clear(); + this.subagentToolResults.clear(); + // Close any open subagent windows + this.cleanupAllFloatingWindows(); + // Update UI + this.renderSubagentPanel(); + this.renderMonitorSubagents(); + this.updateSubagentBadge(); + this.showToast(`Cleared ${data.data.cleared} subagent(s)`, 'success'); + } else { + this.showToast('Failed to clear subagents: ' + data.error, 'error'); + } + } catch (err) { + this.showToast('Failed to clear subagents', 'error'); + } + }, + + toggleSubagentsPanel() { + const panel = document.getElementById('subagentsPanel'); + const toggleBtn = document.getElementById('subagentsToggleBtn'); + if (!panel) return; + + // If hidden, show it first + if (panel.classList.contains('hidden')) { + panel.classList.remove('hidden'); + // Save setting + const settings = this.loadAppSettingsFromStorage(); + settings.showSubagents = true; + this.saveAppSettingsToStorage(settings); + } + + // Toggle open/collapsed state + panel.classList.toggle('open'); + this.subagentPanelVisible = panel.classList.contains('open'); + + // Update toggle button icon + if (toggleBtn) { + toggleBtn.innerHTML = this.subagentPanelVisible ? '▼' : '▲'; // Down when open, up when collapsed + } + + if (this.subagentPanelVisible) { + this.renderSubagentPanel(); + } + }, + + async loadAppSettingsFromServer(settingsPromise = null) { + try { + const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); + if (settings) { + // Extract notification prefs before merging app settings + const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings; + // Filter out display settings — these are device-specific (mobile vs desktop) + // and should not be synced from the server to avoid overriding mobile defaults. + // NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) + // are NOT display keys — they control server-side behavior and must sync from server. + const displayKeys = new Set([ + 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', + 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', + 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', + ]); + // Merge settings: non-display keys always sync from server, + // display keys only seed from server when localStorage has no value + // (prevents cross-device overwrite while fixing settings re-enabling on fresh loads) + const localSettings = this.loadAppSettingsFromStorage(); + const merged = { ...localSettings }; + for (const [key, value] of Object.entries(appSettings)) { + if (displayKeys.has(key)) { + // Display keys: only use server value as initial seed + if (!(key in localSettings)) { + merged[key] = value; + } + } else { + // Non-display keys: server always wins + merged[key] = value; + } + } + this.saveAppSettingsToStorage(merged); + + // Apply notification prefs from server if present (only if localStorage has none) + if (notificationPreferences && this.notificationManager) { + const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey()); + if (!localNotifPrefs) { + this.notificationManager.preferences = notificationPreferences; + this.notificationManager.savePreferences(); + } + } + + // Sync voice settings from server (seed localStorage if no local API key) + if (voiceSettings) { + const localVoice = localStorage.getItem('codeman-voice-settings'); + if (!localVoice || !JSON.parse(localVoice).apiKey) { + VoiceInput._saveDeepgramConfig(voiceSettings); + } + } + + // Sync respawn presets from server (server is source of truth) + if (respawnPresets && Array.isArray(respawnPresets)) { + this._serverRespawnPresets = respawnPresets; + // Also update localStorage for offline access + localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets)); + } else { + // Migration: push existing localStorage presets to server + const localPresets = localStorage.getItem('codeman-respawn-presets'); + if (localPresets) { + const parsed = JSON.parse(localPresets); + if (parsed.length > 0) { + this._serverRespawnPresets = parsed; + this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {}); + } + } + } + + // Sync run mode from server + if (runMode) { + this.runMode = runMode; + try { localStorage.setItem('codeman_runMode', runMode); } catch {} + this._applyRunMode(); + } + + return merged; + } + } catch (err) { + console.error('Failed to load settings from server:', err); + } + return this.loadAppSettingsFromStorage(); + }, + + + /** + * Load subagent window states from server (or localStorage fallback). + * Called on page load to restore minimized/open window states. + */ + async loadSubagentWindowStates() { + let states = null; + + // Try server first for cross-browser sync + try { + const res = await fetch('/api/subagent-window-states'); + if (res.ok) { + states = await res.json(); + // Also update localStorage + localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states)); + } + } catch (err) { + console.error('Failed to load subagent window states from server:', err); + } + + // Fallback to localStorage + if (!states) { + try { + const saved = localStorage.getItem('codeman-subagent-window-states'); + if (saved) { + states = JSON.parse(saved); + } + } catch (err) { + console.error('Failed to load subagent window states from localStorage:', err); + } + } + + return states || { minimized: {}, open: [] }; + }, + + + // ═══════════════════════════════════════════════════════════════ + // Persistent Parent Associations + // ═══════════════════════════════════════════════════════════════ + // This is the ROCK-SOLID system for tracking which tab an agent belongs to. + // Once an agent's parent is discovered, it's saved here PERMANENTLY. + + /** + * Save the subagent parent map to localStorage and server. + * Called whenever a new parent association is discovered. + */ + async saveSubagentParentMap() { + const mapData = Object.fromEntries(this.subagentParentMap); + + // Save to localStorage for instant recovery + try { + localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); + } catch (err) { + console.error('Failed to save subagent parents to localStorage:', err); + } + + // Save to server for cross-browser/session persistence + try { + await fetch('/api/subagent-parents', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mapData) + }); + } catch (err) { + console.error('Failed to save subagent parents to server:', err); + } + }, + + /** + * Load the subagent parent map from server (or localStorage fallback). + * Called once on page load, before any agents are discovered. + */ + async loadSubagentParentMap() { + let mapData = null; + + // Try server first (most authoritative) + try { + const res = await fetch('/api/subagent-parents'); + if (res.ok) { + mapData = await res.json(); + // Update localStorage as cache + localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData)); + } + } catch (err) { + console.error('Failed to load subagent parents from server:', err); + } + + // Fallback to localStorage + if (!mapData) { + try { + const saved = localStorage.getItem('codeman-subagent-parents'); + if (saved) { + mapData = JSON.parse(saved); + } + } catch (err) { + console.error('Failed to load subagent parents from localStorage:', err); + } + } + + // Populate the map (prune stale entries: require both session and agent to exist) + if (mapData && typeof mapData === 'object') { + for (const [agentId, sessionId] of Object.entries(mapData)) { + if (this.sessions.has(sessionId) && this.subagents.has(agentId)) { + this.subagentParentMap.set(agentId, sessionId); + } + } + } + }, + + /** + * Get the parent session ID for an agent from the persistent map. + * This is the ONLY source of truth for connection lines. + */ + getAgentParentSessionId(agentId) { + return this.subagentParentMap.get(agentId) || null; + }, + + /** + * Set and persist the parent session ID for an agent. + * Once set, this association is PERMANENT and never recalculated. + */ + setAgentParentSessionId(agentId, sessionId) { + if (!agentId || !sessionId) return; + + // Only set if not already set (first association wins) + if (this.subagentParentMap.has(agentId)) { + return; // Already has a parent, don't override + } + + this.subagentParentMap.set(agentId, sessionId); + this.saveSubagentParentMap(); // Persist immediately + + // Also update the agent object for consistency + const agent = this.subagents.get(agentId); + if (agent) { + agent.parentSessionId = sessionId; + const session = this.sessions.get(sessionId); + if (session) { + agent.parentSessionName = this.getSessionName(session); + } + this.subagents.set(agentId, agent); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Help Modal + // ═══════════════════════════════════════════════════════════════ + + showHelp() { + const modal = document.getElementById('helpModal'); + modal.classList.add('active'); + + // Activate focus trap + this.activeFocusTrap = new FocusTrap(modal); + this.activeFocusTrap.activate(); + }, + + closeHelp() { + document.getElementById('helpModal').classList.remove('active'); + + // Deactivate focus trap and restore focus + if (this.activeFocusTrap) { + this.activeFocusTrap.deactivate(); + this.activeFocusTrap = null; + } + }, + + closeAllPanels() { + this.closeSessionOptions(); + this.closeAppSettings(); + this.cancelCloseSession(); + this.closeTokenStats(); + document.getElementById('monitorPanel').classList.remove('open'); + // Collapse subagents panel (don't hide it permanently) + const subagentsPanel = document.getElementById('subagentsPanel'); + if (subagentsPanel) { + subagentsPanel.classList.remove('open'); + } + this.subagentPanelVisible = false; + }, +}); diff --git a/src/web/public/subagent-windows.js b/src/web/public/subagent-windows.js index 8cf453b0..0f7c7b76 100644 --- a/src/web/public/subagent-windows.js +++ b/src/web/public/subagent-windows.js @@ -15,7 +15,7 @@ * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.minimizedSubagents) * @dependency constants.js (escapeHtml) - * @loadorder 9 of 9 — loaded last, after api-client.js + * @loadorder 15 of 15 — loaded last, after api-client.js */ // Codeman — Subagent window management for CodemanApp @@ -991,7 +991,7 @@ Object.assign(CodemanApp.prototype, { } // Clean up wizard drag listeners (leak fix: document-level handlers) - this.cleanupWizardDragging(); + if (typeof this.cleanupWizardDragging === 'function') this.cleanupWizardDragging(); // Deactivate focus trap if wizard was open (leak fix: keydown listener) if (this.activeFocusTrap) { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js new file mode 100644 index 00000000..cb29c3e7 --- /dev/null +++ b/src/web/public/terminal-ui.js @@ -0,0 +1,1292 @@ +/** + * @fileoverview Terminal setup (xterm.js config, input, resize, link provider), rendering pipeline + * (batch writes, flicker filter, chunked writes, local echo), terminal controls (clear, font, resize), + * and directory input. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions) + * @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants) + * @dependency mobile-handlers.js (MobileDetection) + * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js + * @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay) + * @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js + */ + +Object.assign(CodemanApp.prototype, { + // ═══════════════════════════════════════════════════════════════ + // Terminal Setup — xterm.js config and input handling + // ═══════════════════════════════════════════════════════════════ + + initTerminal() { + // Load scrollback setting from localStorage (default 5000) + const scrollback = parseInt(localStorage.getItem('codeman-scrollback')) || DEFAULT_SCROLLBACK; + + this.terminal = new Terminal({ + theme: { + background: '#0d0d0d', + foreground: '#e0e0e0', + cursor: '#e0e0e0', + cursorAccent: '#0d0d0d', + selection: 'rgba(255, 255, 255, 0.3)', + black: '#0d0d0d', + red: '#ff6b6b', + green: '#51cf66', + yellow: '#ffd43b', + blue: '#339af0', + magenta: '#cc5de8', + cyan: '#22b8cf', + white: '#e0e0e0', + brightBlack: '#495057', + brightRed: '#ff8787', + brightGreen: '#69db7c', + brightYellow: '#ffe066', + brightBlue: '#5c7cfa', + brightMagenta: '#da77f2', + brightCyan: '#66d9e8', + brightWhite: '#ffffff', + }, + fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', + // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) + fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, + lineHeight: 1.2, + cursorBlink: false, + cursorStyle: 'block', + scrollback: scrollback, + allowTransparency: true, + allowProposedApi: true, + }); + + this.fitAddon = new FitAddon.FitAddon(); + this.terminal.loadAddon(this.fitAddon); + + if (typeof Unicode11Addon !== 'undefined') { + try { + const unicode11Addon = new Unicode11Addon.Unicode11Addon(); + this.terminal.loadAddon(unicode11Addon); + this.terminal.unicode.activeVersion = '11'; + } catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ } + } + + const container = document.getElementById('terminalContainer'); + this.terminal.open(container); + + // Suppress xterm key handling during CJK IME composition. + // Without this, xterm processes raw keyDown events (e.g., "Process" key) + // during composition, causing duplicate or garbled input. + this.terminal.attachCustomKeyEventHandler((ev) => { + if (ev.isComposing || ev.keyCode === 229) return false; + return true; + }); + + // WebGL renderer for GPU-accelerated terminal rendering. + // Previously caused "page unresponsive" crashes from synchronous GPU stalls, + // but the 48KB/frame flush cap in flushPendingWrites() now prevents + // oversized terminal.write() calls that triggered the stalls. + // Disable with ?nowebgl URL param if GPU issues return. + // Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile). + this._webglAddon = null; + const skipWebGL = MobileDetection.getDeviceType() !== 'desktop'; + if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl')) { + if (typeof WebglAddon !== 'undefined') { + this._initWebGL(); + } else { + // Lazy-load WebGL addon — not bundled in to avoid blocking mobile + const wglScript = document.createElement('script'); + wglScript.src = 'vendor/xterm-addon-webgl.min.js'; + wglScript.onload = () => this._initWebGL(); + wglScript.onerror = () => console.warn('[CRASH-DIAG] Failed to load WebGL addon — using canvas renderer'); + document.head.appendChild(wglScript); + } + } + + this._localEchoOverlay = new LocalEchoOverlay(this.terminal); + + // CJK IME input — textarea in index.html, just wire up send + this._cjkInput = null; + if (typeof CjkInput !== 'undefined') { + this._cjkInput = CjkInput.init({ + send: (text) => { + if (this.activeSessionId) { + this._sendInputAsync(this.activeSessionId, text); + } + }, + }); + } + + // On mobile Safari, delay initial fit() to allow layout to settle + // This prevents 0-column terminals caused by fit() running before container is sized + const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' && + document.body.classList.contains('safari-browser'); + if (isMobileSafari) { + // Wait for layout, then fit multiple times to ensure proper sizing + requestAnimationFrame(() => { + this.fitAddon.fit(); + // Double-check after another frame + requestAnimationFrame(() => this.fitAddon.fit()); + }); + } else { + this.fitAddon.fit(); + } + + // Register link provider for clickable file paths in Bash tool output + this.registerFilePathLinkProvider(); + + // Always use mouse wheel for terminal scrollback, never forward to application. + // Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation. + container.addEventListener('wheel', (ev) => { + ev.preventDefault(); + const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1); + this.terminal.scrollLines(lines); + }, { passive: false }); + + // Touch scrolling - only use custom JS scrolling on desktop + // Mobile uses native browser scrolling via CSS touch-action: pan-y + const isMobileDevice = MobileDetection.isTouchDevice() && window.innerWidth < 1024; + + if (!isMobileDevice) { + // Desktop touch scrolling with custom momentum + let touchLastY = 0; + let pendingDelta = 0; + let velocity = 0; + let lastTime = 0; + let scrollFrame = null; + let isTouching = false; + + const viewport = container.querySelector('.xterm-viewport'); + + // Single RAF loop handles both touch and momentum + const scrollLoop = (timestamp) => { + if (!viewport) return; + + const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; // Normalize to 60fps + lastTime = timestamp; + + if (isTouching) { + // During touch: apply pending delta + if (pendingDelta !== 0) { + viewport.scrollTop += pendingDelta; + pendingDelta = 0; + } + scrollFrame = requestAnimationFrame(scrollLoop); + } else if (Math.abs(velocity) > 0.1) { + // Momentum phase + viewport.scrollTop += velocity * dt; + velocity *= 0.94; // Smooth deceleration + scrollFrame = requestAnimationFrame(scrollLoop); + } else { + scrollFrame = null; + velocity = 0; + } + }; + + container.addEventListener('touchstart', (ev) => { + if (ev.touches.length === 1) { + touchLastY = ev.touches[0].clientY; + pendingDelta = 0; + velocity = 0; + isTouching = true; + lastTime = 0; + if (!scrollFrame) { + scrollFrame = requestAnimationFrame(scrollLoop); + } + } + }, { passive: true }); + + container.addEventListener('touchmove', (ev) => { + if (ev.touches.length === 1 && isTouching) { + const touchY = ev.touches[0].clientY; + const delta = touchLastY - touchY; + pendingDelta += delta; + velocity = delta * 1.2; // Track for momentum + touchLastY = touchY; + } + }, { passive: true }); + + container.addEventListener('touchend', () => { + isTouching = false; + // Momentum continues in scrollLoop + }, { passive: true }); + + container.addEventListener('touchcancel', () => { + isTouching = false; + velocity = 0; + }, { passive: true }); + } + // Mobile: native scrolling handles touch via CSS + + // Welcome message + this.showWelcome(); + + // Handle resize with throttling for performance + this._resizeTimeout = null; + this._lastResizeDims = null; + + // Minimum terminal dimensions to prevent vertical text wrapping + const MIN_COLS = 40; + const MIN_ROWS = 10; + + const throttledResize = () => { + // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens + // once after the user stops resizing. During active resize, the terminal + // stays at its old dimensions for up to 300ms. + // + // Why not fit() immediately? Each fitAddon.fit() reflows content at the + // new width — lines that were 7 rows become 10, and the overflow gets + // pushed into scrollback. With continuous resize events, this creates + // dozens of intermediate reflow states in scrollback, appearing as + // duplicate/garbled content when the user scrolls up. + // + // By deferring fit() to the trailing edge, there's exactly ONE reflow + // at the final dimensions, ONE viewport clear, and ONE Ink redraw. + if (this._resizeTimeout) { + clearTimeout(this._resizeTimeout); + } + this._resizeTimeout = setTimeout(() => { + this._resizeTimeout = null; + // Fit xterm.js to final container dimensions + if (this.fitAddon) { + this.fitAddon.fit(); + } + // Flush any stale flicker buffer before clearing viewport + if (this.flickerFilterBuffer) { + if (this.flickerFilterTimeout) { + clearTimeout(this.flickerFilterTimeout); + this.flickerFilterTimeout = null; + } + this.flushFlickerBuffer(); + } + // Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH. + // fitAddon.fit() reflows content: lines at old width may wrap to more rows, + // pushing overflow into scrollback. Ink's cursor-up count is based on the + // pre-reflow line count, so ghost renders accumulate in scrollback. + // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris, + // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw. + const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; + if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended + && this.terminal && this.isTerminalAtBottom()) { + this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); + } + // Skip server resize while mobile keyboard is visible — sending SIGWINCH + // causes Ink to re-render at the new row count, garbling terminal output. + // Local fit() still runs so xterm knows the viewport size for scrolling. + const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + if (this.activeSessionId && !keyboardUp) { + const dims = this.fitAddon.proposeDimensions(); + // Enforce minimum dimensions to prevent layout issues + const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; + const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; + // Only send resize if dimensions actually changed + if (!this._lastResizeDims || + cols !== this._lastResizeDims.cols || + rows !== this._lastResizeDims.rows) { + this._lastResizeDims = { cols, rows }; + fetch(`/api/sessions/${this.activeSessionId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols, rows }) + }).catch(() => {}); + } + } + // Update subagent connection lines and local echo at new dimensions + this.updateConnectionLines(); + if (this._localEchoOverlay?.hasPending) { + this._localEchoOverlay.rerender(); + } + }, 300); // Trailing-edge: only fire after 300ms of no resize events + }; + + window.addEventListener('resize', throttledResize); + // Store resize observer for cleanup (prevents memory leak on terminal re-init) + if (this.terminalResizeObserver) { + this.terminalResizeObserver.disconnect(); + } + this.terminalResizeObserver = new ResizeObserver(throttledResize); + this.terminalResizeObserver.observe(container); + + // Handle keyboard input — send to PTY immediately, no local echo. + // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug). + this._pendingInput = ''; + this._inputFlushTimeout = null; + this._lastKeystrokeTime = 0; + + const flushInput = () => { + this._inputFlushTimeout = null; + if (this._pendingInput && this.activeSessionId) { + const input = this._pendingInput; + const sessionId = this.activeSessionId; + this._pendingInput = ''; + this._sendInputAsync(sessionId, input); + } + }; + + // Local echo mode: buffer keystrokes locally (shown in overlay) and only + // send to PTY on Enter. Avoids out-of-order delivery on high-latency + // mobile connections. The overlay + localStorage persistence ensure input + // survives tab switches and reconnects. + + this.terminal.onData((data) => { + // CJK input has focus — block xterm from sending to PTY + if (window.cjkActive || document.activeElement?.id === 'cjkInput') return; + if (this.activeSessionId) { + // Filter out terminal query responses that xterm.js generates automatically. + // These are responses to DA (Device Attributes), DSR (Device Status Report), etc. + // sent by tmux when attaching. Without this filter, they appear as typed text. + // Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR) + if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return; + + // ── Local Echo Mode ── + // When enabled, keystrokes are buffered locally in the overlay for + // instant visual feedback. Nothing is sent to the PTY until Enter + // (or a control char) is pressed — avoids out-of-order char delivery. + if (this._localEchoEnabled) { + if (data === '\x7f') { + const source = this._localEchoOverlay?.removeChar(); + if (source === 'flushed') { + // Sync app-level flushed Maps (per-session state for tab switching) + const { count, text } = this._localEchoOverlay.getFlushed(); + if (this._flushedOffsets?.has(this.activeSessionId)) { + if (count === 0) { + this._flushedOffsets.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + } else { + this._flushedOffsets.set(this.activeSessionId, count); + this._flushedTexts?.set(this.activeSessionId, text); + } + } + this._pendingInput += data; + flushInput(); + } + // 'pending' = removed unsent text (no PTY backspace needed) + // false = nothing to remove (swallow the backspace) + return; + } + if (/^[\r\n]+$/.test(data)) { + // Enter: send full buffered text + \r to PTY in one shot + const text = this._localEchoOverlay?.pendingText || ''; + this._localEchoOverlay?.clear(); + // Suppress detection so PTY-echoed text isn't re-detected as user input + this._localEchoOverlay?.suppressBufferDetection(); + // Clear flushed offset and text — Enter commits all text + this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + if (this._inputFlushTimeout) { + clearTimeout(this._inputFlushTimeout); + this._inputFlushTimeout = null; + } + if (text) { + this._pendingInput += text; + flushInput(); + } + // Send \r after a short delay so text arrives first + setTimeout(() => { + this._pendingInput += '\r'; + flushInput(); + }, 80); + return; + } + if (data.length > 1 && data.charCodeAt(0) >= 32) { + // Paste: append to overlay only (sent on Enter) + this._localEchoOverlay?.appendText(data); + return; + } + if (data.charCodeAt(0) < 32) { + // Skip xterm-generated terminal responses. + // These arrive via triggerDataEvent when the terminal processes + // buffer data (DA responses, OSC color queries, mode reports, etc.). + // They are NOT user input and must not clear flushed text state. + // Covers: CSI (\x1b[), OSC (\x1b]), DCS (\x1bP), APC (\x1b_), + // PM (\x1b^), SOS (\x1bX), and any other multi-byte ESC sequence. + // Single-byte ESC (user pressing Escape) still falls through to + // the control char handler below. + if (data.length > 1 && data.charCodeAt(0) === 27) { + // Multi-byte escape sequence — forward to PTY without clearing + // overlay/flushed state (terminal response, not user input) + this._pendingInput += data; + flushInput(); + return; + } + // During buffer load (tab switch), stray control chars from + // terminal response processing must not wipe the flushed state + // that selectSession() is actively restoring. + if (this._restoringFlushedState) { + this._pendingInput += data; + flushInput(); + return; + } + // Tab key: send pending text + Tab to PTY for tab completion. + // Set a flag so flushPendingWrites() re-detects buffer text when + // the PTY response arrives (event-driven, no fixed timer). + if (data === '\t') { + const text = this._localEchoOverlay?.pendingText || ''; + this._localEchoOverlay?.clear(); + this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + if (text) { + this._pendingInput += text; + } + this._pendingInput += data; + if (this._inputFlushTimeout) { + clearTimeout(this._inputFlushTimeout); + this._inputFlushTimeout = null; + } + // Snapshot prompt line text BEFORE flushing — used to distinguish + // real Tab completions from pre-existing Claude UI text. + let baseText = ''; + try { + const p = this._localEchoOverlay?.findPrompt?.(); + if (p) { + const buf = this.terminal.buffer.active; + const line = buf.getLine(buf.viewportY + p.row); + if (line) baseText = line.translateToString(true).slice(p.col + 2).trimEnd(); + } + } catch {} + this._tabCompletionBaseText = baseText; + flushInput(); + this._tabCompletionSessionId = this.activeSessionId; + this._tabCompletionRetries = 0; + // Fallback: if flushPendingWrites() detection misses the completion + // (e.g., flicker filter delays data, or xterm hasn't processed writes + // by the time the callback fires), retry detection after a delay. + // This ensures the overlay renders even without further terminal data. + if (this._tabCompletionFallback) clearTimeout(this._tabCompletionFallback); + const selfTab = this; + this._tabCompletionFallback = setTimeout(() => { + selfTab._tabCompletionFallback = null; + if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return; + const ov = selfTab._localEchoOverlay; + if (!ov || ov.pendingText) return; + selfTab.terminal.write('', () => { + if (!selfTab._tabCompletionSessionId) return; + ov.resetBufferDetection(); + const detected = ov.detectBufferText(); + if (detected && detected !== selfTab._tabCompletionBaseText) { + selfTab._tabCompletionSessionId = null; + selfTab._tabCompletionRetries = 0; + selfTab._tabCompletionBaseText = null; + ov.rerender(); + } + }); + }, 300); + return; + } + // Control chars (Ctrl+C, single ESC): send buffered text + control char immediately + const text = this._localEchoOverlay?.pendingText || ''; + this._localEchoOverlay?.clear(); + // Suppress detection so PTY-echoed text isn't re-detected as user input + this._localEchoOverlay?.suppressBufferDetection(); + // Clear flushed offset and text — control chars (Ctrl+C, Escape) change + // cursor position or abort readline, making flushed text tracking invalid. + this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + if (text) { + this._pendingInput += text; + } + this._pendingInput += data; + if (this._inputFlushTimeout) { + clearTimeout(this._inputFlushTimeout); + this._inputFlushTimeout = null; + } + flushInput(); + return; + } + if (data.length === 1 && data.charCodeAt(0) >= 32) { + // Printable char: add to overlay only (sent on Enter) + this._localEchoOverlay?.addChar(data); + return; + } + } + + // ── Normal Mode (echo disabled) ── + this._pendingInput += data; + + // Control chars (Enter, Ctrl+C, escape sequences) — flush immediately + if (data.charCodeAt(0) < 32 || data.length > 1) { + if (this._inputFlushTimeout) { + clearTimeout(this._inputFlushTimeout); + this._inputFlushTimeout = null; + } + flushInput(); + return; + } + + // Regular chars — flush immediately if typed after a gap (>50ms), + // otherwise batch via microtask to coalesce rapid keystrokes (paste). + const now = performance.now(); + if (now - this._lastKeystrokeTime > 50) { + // Single char after a gap — send immediately, no setTimeout latency + if (this._inputFlushTimeout) { + clearTimeout(this._inputFlushTimeout); + this._inputFlushTimeout = null; + } + this._lastKeystrokeTime = now; + flushInput(); + } else { + // Rapid sequence (paste or fast typing) — coalesce via microtask + this._lastKeystrokeTime = now; + if (!this._inputFlushTimeout) { + this._inputFlushTimeout = setTimeout(flushInput, 0); + } + } + } + }); + }, + + /** + * Register a custom link provider for xterm.js that detects file paths + * in terminal output and makes them clickable. + * When clicked, opens a floating log viewer window with live streaming. + */ + registerFilePathLinkProvider() { + const self = this; + + // Debug: Track if provider is being invoked + let lastInvokedLine = -1; + + this.terminal.registerLinkProvider({ + provideLinks(bufferLineNumber, callback) { + // Debug logging - only log if line changed to avoid spam + if (bufferLineNumber !== lastInvokedLine) { + lastInvokedLine = bufferLineNumber; + console.debug('[LinkProvider] Checking line:', bufferLineNumber); + } + + const buffer = self.terminal.buffer.active; + const line = buffer.getLine(bufferLineNumber); + + if (!line) { + callback(undefined); + return; + } + + // Get line text - translateToString handles wrapped lines + const lineText = line.translateToString(true); + + if (!lineText || !lineText.includes('/')) { + callback(undefined); + return; + } + + const links = []; + + // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.) + // Handles: tail -f /path, grep pattern /path, cat -n /path + const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; + + // Pattern 2: Paths with common extensions + const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g; + + // Pattern 3: Bash() tool output + const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; + + const addLink = (filePath, matchIndex) => { + const startCol = lineText.indexOf(filePath, matchIndex); + if (startCol === -1) return; + + // Skip if already have link at this position + if (links.some(l => l.range.start.x === startCol + 1)) return; + + links.push({ + text: filePath, + range: { + start: { x: startCol + 1, y: bufferLineNumber }, // 1-based + end: { x: startCol + filePath.length + 1, y: bufferLineNumber } + }, + decorations: { + pointerCursor: true, + underline: true + }, + activate(event, text) { + self.openLogViewerWindow(text, self.activeSessionId); + } + }); + }; + + // Match all patterns + let match; + + cmdPattern.lastIndex = 0; + while ((match = cmdPattern.exec(lineText)) !== null) { + addLink(match[2], match.index); + } + + extPattern.lastIndex = 0; + while ((match = extPattern.exec(lineText)) !== null) { + addLink(match[1], match.index); + } + + bashPattern.lastIndex = 0; + while ((match = bashPattern.exec(lineText)) !== null) { + addLink(match[1], match.index); + } + + if (links.length > 0) { + console.debug('[LinkProvider] Found links:', links.map(l => l.text)); + } + callback(links.length > 0 ? links : undefined); + } + }); + + console.log('[LinkProvider] File path link provider registered'); + }, + + showWelcome() { + const overlay = document.getElementById('welcomeOverlay'); + if (overlay) { + overlay.classList.add('visible'); + this.loadTunnelStatus(); + this.loadHistorySessions(); + } + }, + + hideWelcome() { + const overlay = document.getElementById('welcomeOverlay'); + if (overlay) { + overlay.classList.remove('visible'); + } + // Collapse expanded QR when leaving welcome screen + const qrWrap = document.getElementById('welcomeQr'); + if (qrWrap) { + clearTimeout(this._welcomeQrShrinkTimer); + qrWrap.classList.remove('expanded'); + } + }, + + /** + * Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total). + * @returns {Promise} deduplicated session list, sorted by lastModified desc + */ + async _fetchHistorySessions(limit = 12) { + const res = await fetch('/api/history/sessions'); + const data = await res.json(); + const sessions = data.sessions || []; + if (sessions.length === 0) return []; + + const byDir = new Map(); + for (const s of sessions) { + if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []); + byDir.get(s.workingDir).push(s); + } + const items = []; + for (const [, group] of byDir) { + items.push(...group.slice(0, 2)); + } + items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); + return items.slice(0, limit); + }, + + async loadHistorySessions() { + const container = document.getElementById('historySessions'); + const list = document.getElementById('historyList'); + if (!container || !list) return; + + try { + const display = await this._fetchHistorySessions(12); + if (display.length === 0) { + container.style.display = 'none'; + return; + } + + // Build DOM safely (no innerHTML with user data) + list.replaceChildren(); + for (const s of display) { + const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B` + : s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K` + : `${(s.sizeBytes / 1048576).toFixed(1)}M`; + const date = new Date(s.lastModified); + const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); + const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); + + const item = document.createElement('div'); + item.className = 'history-item'; + item.title = s.workingDir; + item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir)); + + const dirSpan = document.createElement('span'); + dirSpan.className = 'history-item-dir'; + dirSpan.textContent = shortDir; + + const metaSpan = document.createElement('span'); + metaSpan.className = 'history-item-meta'; + metaSpan.textContent = timeStr; + + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'history-item-size'; + sizeSpan.textContent = size; + + item.append(dirSpan, metaSpan, sizeSpan); + list.appendChild(item); + } + + container.style.display = ''; + } catch (err) { + console.error('[loadHistorySessions]', err); + container.style.display = 'none'; + } + }, + + async resumeHistorySession(sessionId, workingDir) { + // Close the run mode menu if open + document.getElementById('runModeMenu')?.classList.remove('active'); + try { + this.terminal.clear(); + this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`); + + // Generate a session name from the working dir + const dirName = workingDir.split('/').pop() || 'session'; + let startNumber = 1; + for (const [, session] of this.sessions) { + const match = session.name && session.name.match(/^w(\d+)-/); + if (match) { + const num = parseInt(match[1]); + if (num >= startNumber) startNumber = num + 1; + } + } + const name = `w${startNumber}-${dirName}`; + + // Create session with resumeSessionId + const createRes = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }) + }); + const createData = await createRes.json(); + if (!createData.success) throw new Error(createData.error); + + const newSessionId = createData.session.id; + + // Start interactive + await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' }); + + this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`); + await this.selectSession(newSessionId); + this.terminal.focus(); + } catch (err) { + this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Terminal Rendering + // ═══════════════════════════════════════════════════════════════ + + /** + * Check if terminal viewport is at or near the bottom. + * Used to implement "sticky scroll" behavior - keep user at bottom if they were there. + */ + isTerminalAtBottom() { + if (!this.terminal) return true; + const buffer = this.terminal.buffer.active; + // viewportY is the top line of the viewport, baseY is where scrollback starts + // If viewportY >= baseY, we're showing the latest content (at bottom) + // Allow 2 lines tolerance for edge cases + return buffer.viewportY >= buffer.baseY - 2; + }, + + batchTerminalWrite(data) { + // If a buffer load (chunkedTerminalWrite) is in progress, queue live events + // to prevent interleaving historical buffer data with live SSE data. + // This is critical: interleaving causes cursor position chaos with Ink redraws. + if (this._isLoadingBuffer) { + if (this._loadBufferQueue) this._loadBufferQueue.push(data); + return; + } + + // Check if at bottom BEFORE adding data (captures user's scroll position) + // Only update if not already scheduled (preserve the first check's result) + if (!this.writeFrameScheduled) { + this._wasAtBottomBeforeWrite = this.isTerminalAtBottom(); + } + + // Check if flicker filter is enabled for current session + const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; + const flickerFilterEnabled = session?.flickerFilterEnabled ?? false; + + // xterm.js 6.0 handles DEC 2026 synchronized output natively — Ink's cursor-up + // redraws are wrapped in 2026h/2026l markers and rendered atomically by xterm.js. + // No client-side cursor-up detection/buffering needed. The old 50ms flicker filter + // was actively harmful: it accumulated multiple resize redraws and flushed them + // together, causing stacked ghost renders due to reflow line-count mismatches. + + // Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it) + if (flickerFilterEnabled) { + const hasScreenClear = data.includes('\x1b[2J') || + data.includes('\x1b[H\x1b[J') || + (data.includes('\x1b[H') && data.includes('\x1b[?25l')); + + if (hasScreenClear) { + this.flickerFilterActive = true; + this.flickerFilterBuffer += data; + + if (this.flickerFilterTimeout) { + clearTimeout(this.flickerFilterTimeout); + } + this.flickerFilterTimeout = setTimeout(() => { + this.flickerFilterTimeout = null; + this.flushFlickerBuffer(); + }, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window + + return; + } + + if (this.flickerFilterActive) { + this.flickerFilterBuffer += data; + return; + } + } + + // Accumulate raw data (may contain DEC 2026 markers) + this.pendingWrites.push(data); + + if (!this.writeFrameScheduled) { + this.writeFrameScheduled = true; + requestAnimationFrame(() => { + // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers + // content between 2026h/2026l and renders atomically. No need for + // client-side incomplete-block detection; just flush every frame. + this.flushPendingWrites(); + this.writeFrameScheduled = false; + }); + } + }, + + /** + * Flush the flicker filter buffer to the terminal. + * Called after the buffer window expires. + */ + flushFlickerBuffer() { + if (!this.flickerFilterBuffer) return; + + // Transfer buffered data to normal pending writes + this.pendingWrites.push(this.flickerFilterBuffer); + this.flickerFilterBuffer = ''; + this.flickerFilterActive = false; + + // Trigger a normal flush + if (!this.writeFrameScheduled) { + this.writeFrameScheduled = true; + requestAnimationFrame(() => { + this.flushPendingWrites(); + this.writeFrameScheduled = false; + }); + } + }, + + /** + * Update local echo overlay state based on settings. + * Enabled whenever the setting is on — works during idle AND busy. + * Position is tracked dynamically by _findPrompt() on every render. + */ + _updateLocalEchoState() { + const settings = this.loadAppSettingsFromStorage(); + const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; + const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); + const shouldEnable = !!(echoEnabled && session); + if (this._localEchoEnabled && !shouldEnable) { + this._localEchoOverlay?.clear(); + } + this._localEchoEnabled = shouldEnable; + + // Swap prompt finder based on session mode + if (this._localEchoOverlay && session) { + if (session.mode === 'opencode') { + // OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row. + // The input area is "┃ " — the ┃ is the anchor, offset 3 skips "┃ ". + // We use the cursor row (cursorY) to find the right line, then scan for ┃. + this._localEchoOverlay.setPrompt({ + type: 'custom', + offset: 3, + find: (terminal) => { + try { + const buf = terminal.buffer.active; + const row = buf.cursorY; + const line = buf.getLine(buf.viewportY + row); + if (!line) return null; + const text = line.translateToString(true); + const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL) + if (idx >= 0) return { row, col: idx }; + return null; + } catch { return null; } + } + }); + } else if (session.mode === 'shell') { + // Shell mode: the shell provides its own PTY echo so the overlay isn't needed. + // Disable it by clearing any pending text. + this._localEchoOverlay.clear(); + this._localEchoEnabled = false; + } else { + // Claude Code: scan for ❯ prompt character + this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 }); + } + } + }, + + /** + * Flush pending writes to terminal, processing DEC 2026 sync markers. + * Strips markers and writes content atomically within a single frame. + */ + flushPendingWrites() { + if (this.pendingWrites.length === 0 || !this.terminal) return; + + const _t0 = performance.now(); + // xterm.js 6.0+ natively handles DEC 2026 synchronized output markers. + // Pass raw data through — xterm.js buffers content between markers and + // renders atomically, eliminating split-frame Ink redraws. + const joined = this.pendingWrites.join(''); + this.pendingWrites = []; + const _joinedLen = joined.length; + if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`); + + // Per-frame byte budget to prevent main thread blocking. + // Large writes (141KB+) can freeze Chrome for 2+ minutes. + const MAX_FRAME_BYTES = 65536; // 64KB budget per frame + let deferred = false; + + if (_joinedLen <= MAX_FRAME_BYTES) { + this.terminal.write(joined); + } else { + // Write first chunk now, defer rest to next frame + this.terminal.write(joined.slice(0, MAX_FRAME_BYTES)); + this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES)); + deferred = true; + if (!this.writeFrameScheduled) { + this.writeFrameScheduled = true; + requestAnimationFrame(() => { + this.flushPendingWrites(); + this.writeFrameScheduled = false; + }); + } + } + const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen; + const _dt = performance.now() - _t0; + if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`); + + // Sticky scroll: if user was at bottom, keep them there after new output + if (this._wasAtBottomBeforeWrite) { + this.terminal.scrollToBottom(); + } + + // Re-position local echo overlay after terminal writes — Ink redraws can + // move the ❯ prompt to a different row, making the overlay invisible. + if (this._localEchoOverlay?.hasPending) { + this._localEchoOverlay.rerender(); + } + + // After Tab completion: detect the completed text in the overlay. + // Use terminal.write('', callback) to defer detection until xterm.js + // finishes processing ALL queued writes — direct buffer reads after + // terminal.write(data) can miss text if xterm processes asynchronously. + if (this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId + && this._localEchoOverlay && !this._localEchoOverlay.pendingText) { + const overlay = this._localEchoOverlay; + const self = this; + this.terminal.write('', () => { + if (!self._tabCompletionSessionId) return; // already resolved + overlay.resetBufferDetection(); + const detected = overlay.detectBufferText(); + if (detected) { + if (detected === self._tabCompletionBaseText) { + // Same text as before Tab — no completion yet. Undo and retry. + overlay.undoDetection(); + self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1; + if (self._tabCompletionRetries > 60) { + self._tabCompletionSessionId = null; + self._tabCompletionRetries = 0; + } + } else { + // Text changed — real completion happened + self._tabCompletionSessionId = null; + self._tabCompletionRetries = 0; + self._tabCompletionBaseText = null; + if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; } + overlay.rerender(); + } + } else { + // No text found yet — retry on next flush. + self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1; + if (self._tabCompletionRetries > 60) { + self._tabCompletionSessionId = null; + self._tabCompletionRetries = 0; + } + } + }); + } + }, + + /** + * Write large buffer to terminal in chunks to avoid UI jank. + * Uses requestAnimationFrame to spread work across frames. + * @param {string} buffer - The full terminal buffer to write + * @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps) + * @returns {Promise} - Resolves when all chunks written + */ + chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) { + return new Promise((resolve) => { + if (!buffer || buffer.length === 0) { + this._finishBufferLoad(); + resolve(); + return; + } + + // Block live SSE writes during buffer load to prevent interleaving + this._isLoadingBuffer = true; + this._loadBufferQueue = []; + + // Strip any DEC 2026 markers that might be in the buffer + // (from historical SSE data that was stored with markers) + const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); + + const finish = () => { + this._finishBufferLoad(); + resolve(); + }; + + // For small buffers, write directly — single-frame render is fast enough + if (cleanBuffer.length <= chunkSize) { + this.terminal.write(cleanBuffer); + finish(); + return; + } + + // Large buffers: write in chunks across animation frames. + // Each 32KB chunk keeps per-frame WebGL render work under ~5ms, + // avoiding GPU stalls without needing to toggle the renderer. + let offset = 0; + const _chunkStart = performance.now(); + let _chunkCount = 0; + const writeChunk = () => { + if (offset >= cleanBuffer.length) { + const _totalMs = performance.now() - _chunkStart; + console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`); + // Wait one more frame for xterm to finish rendering before resolving + requestAnimationFrame(finish); + return; + } + + const _ct0 = performance.now(); + const chunk = cleanBuffer.slice(offset, offset + chunkSize); + this.terminal.write(chunk); + const _cdt = performance.now() - _ct0; + _chunkCount++; + if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`); + offset += chunkSize; + + // Schedule next chunk on next frame + requestAnimationFrame(writeChunk); + }; + + // Start writing + requestAnimationFrame(writeChunk); + }); + }, + + /** + * Complete a buffer load: unblock live SSE writes and flush any queued events. + * Called when chunkedTerminalWrite finishes (or is skipped for empty buffers). + */ + _finishBufferLoad() { + const queue = this._loadBufferQueue; + this._isLoadingBuffer = false; + this._loadBufferQueue = null; + if (queue && queue.length > 0) { + for (const data of queue) { + this.batchTerminalWrite(data); + } + } + }, + + + // ═══════════════════════════════════════════════════════════════ + // Terminal Controls + // ═══════════════════════════════════════════════════════════════ + + clearTerminal() { + this.terminal.clear(); + }, + + /** + * Restore terminal size to match web UI dimensions. + * Use this after mobile screen attachment has squeezed the terminal. + * Sends resize to PTY and Ctrl+L to trigger Claude to redraw. + */ + async restoreTerminalSize() { + if (!this.activeSessionId) { + this.showToast('No active session', 'warning'); + return; + } + + const dims = this.getTerminalDimensions(); + if (!dims) { + this.showToast('Could not determine terminal size', 'error'); + return; + } + + try { + // Send resize to restore proper dimensions (with minimum enforcement) + await this.sendResize(this.activeSessionId); + + // Send Ctrl+L to trigger Claude to redraw at new size + await fetch(`/api/sessions/${this.activeSessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: '\x0c' }) + }); + + this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success'); + } catch (err) { + console.error('Failed to restore terminal size:', err); + this.showToast('Failed to restore terminal size', 'error'); + } + }, + + // Send Ctrl+L to fix display for newly created sessions once Claude is running + sendPendingCtrlL(sessionId) { + if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) { + return; + } + this.pendingCtrlL.delete(sessionId); + + // Only send if this is the active session + if (sessionId !== this.activeSessionId) { + return; + } + + // Send resize + Ctrl+L to fix the display (with minimum dimension enforcement) + this.sendResize(sessionId).then(() => { + fetch(`/api/sessions/${sessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: '\x0c' }) + }); + }); + }, + + async copyTerminal() { + try { + const buffer = this.terminal.buffer.active; + let text = ''; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) text += line.translateToString(true) + '\n'; + } + await navigator.clipboard.writeText(text.replace(/\n+$/, '\n')); + this.showToast('Copied to clipboard', 'success'); + } catch (err) { + this.showToast('Failed to copy', 'error'); + } + }, + + increaseFontSize() { + const current = this.terminal.options.fontSize || 14; + this.setFontSize(Math.min(current + 2, 24)); + }, + + decreaseFontSize() { + const current = this.terminal.options.fontSize || 14; + this.setFontSize(Math.max(current - 2, 10)); + }, + + setFontSize(size) { + this.terminal.options.fontSize = size; + document.getElementById('fontSizeDisplay').textContent = size; + this.fitAddon.fit(); + localStorage.setItem('codeman-font-size', size); + // Update overlay font cache and re-render at new cell dimensions + this._localEchoOverlay?.refreshFont(); + }, + + loadFontSize() { + const saved = localStorage.getItem('codeman-font-size'); + if (saved) { + const size = parseInt(saved, 10); + if (size >= 10 && size <= 24) { + this.terminal.options.fontSize = size; + document.getElementById('fontSizeDisplay').textContent = size; + } + } + }, + + /** + * Get terminal dimensions with minimum enforcement. + * Prevents extremely narrow terminals that cause vertical text wrapping. + * @returns {{cols: number, rows: number}|null} + */ + getTerminalDimensions() { + const MIN_COLS = 40; + const MIN_ROWS = 10; + const dims = this.fitAddon?.proposeDimensions(); + if (!dims) return null; + return { + cols: Math.max(dims.cols, MIN_COLS), + rows: Math.max(dims.rows, MIN_ROWS) + }; + }, + + /** + * Send resize to a session with minimum dimension enforcement. + * @param {string} sessionId + * @returns {Promise} + */ + async sendResize(sessionId) { + const dims = this.getTerminalDimensions(); + if (!dims) return; + // Fast path: WebSocket resize + if (this._wsReady && this._wsSessionId === sessionId) { + try { + this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows })); + return; + } catch { + // Fall through to HTTP POST + } + } + await fetch(`/api/sessions/${sessionId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dims) + }); + }, + + /** + * Send input to the active session. + * @param {string} input - Text to send (include \r for Enter) + * @returns {Promise} + */ + async sendInput(input) { + if (!this.activeSessionId) return; + await fetch(`/api/sessions/${this.activeSessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input, useMux: true }) + }); + }, + + + // ═══════════════════════════════════════════════════════════════ + // Directory Input + // ═══════════════════════════════════════════════════════════════ + + toggleDirInput() { + const btn = document.querySelector('#dirDisplay').parentElement; + const input = document.getElementById('dirInput'); + + if (input.classList.contains('hidden')) { + input.classList.remove('hidden'); + btn.style.display = 'none'; + input.focus(); + } + }, + + hideDirInput() { + const btn = document.querySelector('#dirDisplay').parentElement; + const input = document.getElementById('dirInput'); + + setTimeout(() => { + input.classList.add('hidden'); + btn.style.display = ''; + + const value = input.value.trim(); + document.getElementById('dirDisplay').textContent = value || 'No directory'; + }, 100); + }, +}); diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index bf4fbbd5..4f79d038 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -19,7 +19,7 @@ * * @dependency mobile-handlers.js (MobileDetection for device checks) * @dependency app.js (uses global `app` for sendInput, showToast, terminal focus) - * @loadorder 3 of 9 — loaded after mobile-handlers.js, before notification-manager.js + * @loadorder 3 of 15 — loaded after mobile-handlers.js, before notification-manager.js */ // Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback