From 192690911f8acc19ce22d527de3ef8d00783a8d1 Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 14 Mar 2026 21:24:22 +0100 Subject: [PATCH] refactor: extract app.js into 6 domain modules with deferred init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the monolithic app.js (~12.5K lines) into 6 focused mixin modules that extend CodemanApp.prototype via Object.assign: - terminal-ui.js — terminal setup, rendering pipeline, controls - respawn-ui.js — respawn banner, countdown, presets, run summary - ralph-panel.js — Ralph state panel, fix_plan, plan versioning - settings-ui.js — app settings, visibility, web push, tunnel/QR, help - panels-ui.js — subagent panel, teams, insights, file browser, log viewer - session-ui.js — quick start, session options, case settings Fix deferred script init ordering: wrap CodemanApp instantiation in DOMContentLoaded so all defer'd mixin modules execute their Object.assign before the constructor runs. Without this, init() calls methods like applyHeaderVisibilitySettings() that don't exist yet. Guard missing cleanupWizardDragging() call in subagent-windows.js. Update build.mjs to minify/hash all new modules. Update CLAUDE.md with new frontend architecture and load order. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 +- scripts/build.mjs | 12 + src/web/public/api-client.js | 2 +- src/web/public/app.js | 10145 +---------------------- src/web/public/constants.js | 6 +- src/web/public/index.html | 6 + src/web/public/input-cjk.js | 2 +- src/web/public/keyboard-accessory.js | 2 +- src/web/public/mobile-handlers.js | 2 +- src/web/public/notification-manager.js | 2 +- src/web/public/panels-ui.js | 3284 ++++++++ src/web/public/ralph-panel.js | 1221 +++ src/web/public/ralph-wizard.js | 2 +- src/web/public/respawn-ui.js | 1185 +++ src/web/public/session-ui.js | 1224 +++ src/web/public/settings-ui.js | 1935 +++++ src/web/public/subagent-windows.js | 4 +- src/web/public/terminal-ui.js | 1292 +++ src/web/public/voice-input.js | 2 +- 19 files changed, 10223 insertions(+), 10109 deletions(-) create mode 100644 src/web/public/panels-ui.js create mode 100644 src/web/public/ralph-panel.js create mode 100644 src/web/public/respawn-ui.js create mode 100644 src/web/public/session-ui.js create mode 100644 src/web/public/settings-ui.js create mode 100644 src/web/public/terminal-ui.js 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