// Claudeman App - Tab-based Terminal UI // ============================================================================ // Constants // ============================================================================ // Default terminal scrollback (can be changed via settings) const DEFAULT_SCROLLBACK = 5000; // Timing constants const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping window const NOTIFICATION_LIST_CAP = 100; // Max notifications in list const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const THROTTLE_DELAY_MS = 100; // General UI throttle delay const TERMINAL_CHUNK_SIZE = 64 * 1024; // 64KB chunks for terminal data const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling // DEC mode 2026 - Synchronized Output // Wrap terminal writes with these markers to prevent partial-frame flicker. // Terminal buffers all output between markers and renders atomically. // Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal // xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves. const DEC_SYNC_START = '\x1b[?2026h'; const DEC_SYNC_END = '\x1b[?2026l'; /** * Process data containing DEC 2026 sync markers. * Strips markers and returns segments that should be written atomically. * Each returned segment represents content between SYNC_START and SYNC_END. * Content outside sync blocks is returned as-is. * * @param {string} data - Raw terminal data with potential sync markers * @returns {string[]} - Array of content segments to write (markers stripped) */ function extractSyncSegments(data) { const segments = []; let remaining = data; while (remaining.length > 0) { const startIdx = remaining.indexOf(DEC_SYNC_START); if (startIdx === -1) { // No more sync blocks, return rest as-is if (remaining.length > 0) { segments.push(remaining); } break; } // Content before sync block (if any) if (startIdx > 0) { segments.push(remaining.slice(0, startIdx)); } // Find matching end marker const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length); const endIdx = afterStart.indexOf(DEC_SYNC_END); if (endIdx === -1) { // No end marker found - sync block continues in next chunk // Include the start marker so it can be handled when more data arrives segments.push(remaining.slice(startIdx)); break; } // Extract synchronized content (without markers) const syncContent = afterStart.slice(0, endIdx); if (syncContent.length > 0) { segments.push(syncContent); } // Continue with content after end marker remaining = afterStart.slice(endIdx + DEC_SYNC_END.length); } return segments; } // Notification Manager - Multi-layer browser notification system class NotificationManager { constructor(app) { this.app = app; this.notifications = []; this.unreadCount = 0; this.isTabVisible = !document.hidden; this.isDrawerOpen = false; this.originalTitle = document.title; this.titleFlashInterval = null; this.titleFlashState = false; this.lastBrowserNotifTime = 0; this.audioCtx = null; this.renderScheduled = false; // Debounce grouping: Map this.groupingMap = new Map(); // Load preferences this.preferences = this.loadPreferences(); // Visibility tracking document.addEventListener('visibilitychange', () => { this.isTabVisible = !document.hidden; if (this.isTabVisible) { this.onTabVisible(); } }); } loadPreferences() { const defaults = { enabled: true, browserNotifications: true, audioAlerts: false, stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS, muteCritical: false, muteWarning: false, muteInfo: false, _version: 2, }; try { const saved = localStorage.getItem('claudeman-notification-prefs'); if (saved) { const prefs = JSON.parse(saved); // Migrate: v1 had browserNotifications defaulting to false if (!prefs._version || prefs._version < 2) { prefs.browserNotifications = true; prefs._version = 2; localStorage.setItem('claudeman-notification-prefs', JSON.stringify(prefs)); } return { ...defaults, ...prefs }; } } catch (_e) { /* ignore */ } return defaults; } savePreferences() { localStorage.setItem('claudeman-notification-prefs', JSON.stringify(this.preferences)); } notify({ urgency, category, sessionId, sessionName, title, message }) { if (!this.preferences.enabled) return; // Check urgency muting if (urgency === 'critical' && this.preferences.muteCritical) return; if (urgency === 'warning' && this.preferences.muteWarning) return; if (urgency === 'info' && this.preferences.muteInfo) return; // Grouping: same category+session within 5s updates count instead of new entry const groupKey = `${category}:${sessionId || 'global'}`; const existing = this.groupingMap.get(groupKey); if (existing) { existing.notification.count = (existing.notification.count || 1) + 1; existing.notification.message = message; existing.notification.timestamp = Date.now(); clearTimeout(existing.timeout); existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); this.scheduleRender(); return; } const notification = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 7), urgency, category, sessionId, sessionName, title, message, timestamp: Date.now(), read: false, count: 1, }; // Add to log (cap at 100) this.notifications.unshift(notification); if (this.notifications.length > 100) this.notifications.pop(); // Track for grouping const timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); this.groupingMap.set(groupKey, { notification, timeout }); // Update unread this.unreadCount++; this.updateBadge(); this.scheduleRender(); // Layer 2: Tab title (when tab unfocused) if (!this.isTabVisible) { this.updateTabTitle(); } // Layer 3: Browser notification (critical/warning always, info only when tab hidden) if (urgency === 'critical' || urgency === 'warning' || !this.isTabVisible) { this.sendBrowserNotif(title, message, category, sessionId); } // Layer 4: Audio alert (critical only) if (urgency === 'critical' && this.preferences.audioAlerts) { this.playAudioAlert(); } } // Layer 1: Drawer rendering scheduleRender() { if (this.renderScheduled) return; this.renderScheduled = true; requestAnimationFrame(() => { this.renderScheduled = false; this.renderDrawer(); }); } renderDrawer() { const list = document.getElementById('notifList'); const empty = document.getElementById('notifEmpty'); if (!list || !empty) return; if (this.notifications.length === 0) { list.style.display = 'none'; empty.style.display = 'flex'; return; } list.style.display = 'block'; empty.style.display = 'none'; list.innerHTML = this.notifications.map(n => { const urgencyClass = `notif-item-${n.urgency}`; const readClass = n.read ? '' : ' unread'; const countLabel = n.count > 1 ? `×${n.count}` : ''; const sessionChip = n.sessionName ? `${this.escapeHtml(n.sessionName)}` : ''; return `
${this.escapeHtml(n.title)}${countLabel} ${this.relativeTime(n.timestamp)}
${this.escapeHtml(n.message)}
${sessionChip}
`; }).join(''); } // Layer 2: Tab title with unread count updateTabTitle() { if (this.unreadCount > 0 && !this.isTabVisible) { if (!this.titleFlashInterval) { this.titleFlashInterval = setInterval(() => { this.titleFlashState = !this.titleFlashState; document.title = this.titleFlashState ? `\u26A0\uFE0F (${this.unreadCount}) Claudeman` : this.originalTitle; }, TITLE_FLASH_INTERVAL_MS); // Set immediately document.title = `\u26A0\uFE0F (${this.unreadCount}) Claudeman`; } } } stopTitleFlash() { if (this.titleFlashInterval) { clearInterval(this.titleFlashInterval); this.titleFlashInterval = null; this.titleFlashState = false; document.title = this.originalTitle; } } // Layer 3: Web Notification API sendBrowserNotif(title, body, tag, sessionId) { if (!this.preferences.browserNotifications) return; if (typeof Notification === 'undefined') return; if (Notification.permission === 'default') { // Auto-request on first notification attempt Notification.requestPermission().then(result => { if (result === 'granted') { // Re-send this notification now that we have permission this.sendBrowserNotif(title, body, tag, sessionId); } }); return; } if (Notification.permission !== 'granted') return; // Rate limit: max 1 per 3 seconds const now = Date.now(); if (now - this.lastBrowserNotifTime < 3000) return; this.lastBrowserNotifTime = now; const notif = new Notification(`Claudeman: ${title}`, { body, tag, // Groups same-tag notifications icon: '/favicon.ico', silent: true, // We handle audio ourselves }); notif.onclick = () => { window.focus(); if (sessionId && this.app.sessions.has(sessionId)) { this.app.selectSession(sessionId); } notif.close(); }; // Auto-close after 8s setTimeout(() => notif.close(), 8000); } async requestPermission() { if (typeof Notification === 'undefined') { this.app.showToast('Browser notifications not supported', 'warning'); return; } const result = await Notification.requestPermission(); const statusEl = document.getElementById('notifPermissionStatus'); if (statusEl) statusEl.textContent = `Status: ${result}`; if (result === 'granted') { this.preferences.browserNotifications = true; this.savePreferences(); this.app.showToast('Notifications enabled', 'success'); } else { this.app.showToast(`Permission ${result}`, 'warning'); } } // Layer 4: Audio alert via Web Audio API playAudioAlert() { try { if (!this.audioCtx) { this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } const ctx = this.audioCtx; const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(660, ctx.currentTime); gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.15); } catch (_e) { /* Audio not available */ } } // UI interactions toggleDrawer() { const drawer = document.getElementById('notifDrawer'); if (!drawer) return; this.isDrawerOpen = !this.isDrawerOpen; drawer.classList.toggle('open', this.isDrawerOpen); if (this.isDrawerOpen) { this.renderDrawer(); } } clickNotification(notifId) { const notif = this.notifications.find(n => n.id === notifId); if (!notif) return; // Mark as read if (!notif.read) { notif.read = true; this.unreadCount = Math.max(0, this.unreadCount - 1); this.updateBadge(); } // Switch to session if available if (notif.sessionId && this.app.sessions.has(notif.sessionId)) { this.app.selectSession(notif.sessionId); this.toggleDrawer(); } this.scheduleRender(); } markAllRead() { this.notifications.forEach(n => { n.read = true; }); this.unreadCount = 0; this.updateBadge(); this.stopTitleFlash(); this.scheduleRender(); } clearAll() { this.notifications = []; this.unreadCount = 0; this.updateBadge(); this.stopTitleFlash(); this.scheduleRender(); } updateBadge() { const badge = document.getElementById('notifBadge'); if (!badge) return; if (this.unreadCount > 0) { badge.style.display = 'flex'; badge.textContent = this.unreadCount > 99 ? '99+' : String(this.unreadCount); } else { badge.style.display = 'none'; } } onTabVisible() { this.stopTitleFlash(); // If drawer is open, mark all as read if (this.isDrawerOpen) { this.markAllRead(); } } // Utilities relativeTime(ts) { const diff = Math.floor((Date.now() - ts) / 1000); if (diff < 60) return 'now'; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return `${Math.floor(diff / 86400)}d ago`; } escapeHtml(text) { if (!text) return ''; return text.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } } class ClaudemanApp { constructor() { this.sessions = new Map(); this.cases = []; this.currentRun = null; this.totalTokens = 0; this.globalStats = null; // Global token/cost stats across all sessions this.eventSource = null; this.terminal = null; this.fitAddon = null; this.activeSessionId = null; this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } } this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20) this.timerCountdownInterval = null; // Interval for updating countdown display this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation this.screenSessions = []; // Screen sessions for process monitor // Ralph loop/todo state per session this.ralphStates = new Map(); // Map // Subagent (Claude Code background agent) tracking this.subagents = new Map(); // Map this.subagentActivity = new Map(); // Map - recent tool calls/progress this.subagentToolResults = new Map(); // Map> - tool results by toolUseId this.activeSubagentId = null; // Currently selected subagent for detail view this.subagentPanelVisible = false; this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = 1000; this.minimizedSubagents = new Map(); // Map> - minimized to tab this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide this.ralphStatePanelCollapsed = true; // Default to collapsed // Plan subagent windows (visible Opus agents during plan generation) this.planSubagents = new Map(); // Map this.planSubagentWindowZIndex = 1100; this.planGenerationStopped = false; // Flag to ignore SSE events after Stop this.planAgentsMinimized = false; // Whether agent windows are minimized to tab // Wizard dragging state this.wizardDragState = null; // { startX, startY, startLeft, startTop, isDragging } this.wizardDragListeners = null; // { move, up } for cleanup this.wizardPosition = null; // { left, top } - null means centered // Project Insights tracking (active Bash tools with clickable file paths) this.projectInsights = new Map(); // Map this.logViewerWindows = new Map(); // Map this.logViewerWindowZIndex = 2000; this.projectInsightsPanelVisible = false; this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization // Image popup windows (auto-open for detected screenshots/images) this.imagePopups = new Map(); // Map this.imagePopupZIndex = 3000; // Tab alert states: Map this.tabAlerts = new Map(); // Pending hooks per session: Map> // Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt) this.pendingHooks = new Map(); // Terminal write batching with DEC 2026 sync support this.pendingWrites = ''; this.writeFrameScheduled = false; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll this.syncWaitTimeout = null; // Timeout for incomplete sync blocks // Render debouncing this.renderSessionTabsTimeout = null; this.renderRalphStatePanelTimeout = null; this.renderTaskPanelTimeout = null; this.renderScreenSessionsTimeout = null; // System stats polling this.systemStatsInterval = null; // SSE reconnect timeout (to prevent orphaned timeouts) this.sseReconnectTimeout = null; // SSE event listener cleanup function (to prevent listener accumulation on reconnect) this._sseListenerCleanup = null; // Notification system this.notificationManager = new NotificationManager(this); this.idleTimers = new Map(); // Map for stuck detection // DOM element cache for performance (avoid repeated getElementById calls) this._elemCache = {}; this.init(); } // Cached element getter - avoids repeated DOM queries $(id) { if (!this._elemCache[id]) { this._elemCache[id] = document.getElementById(id); } return this._elemCache[id]; } // Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500 formatTokens(count) { if (count >= 1000000) { const m = count / 1000000; return m >= 10 ? `${m.toFixed(1)}m` : `${m.toFixed(2)}m`; } else if (count >= 1000) { const k = count / 1000; return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`; } return String(count); } // Estimate cost from tokens using Claude Opus pricing // Input: $15/M tokens, Output: $75/M tokens estimateCost(inputTokens, outputTokens) { const inputCost = (inputTokens / 1000000) * 15; const outputCost = (outputTokens / 1000000) * 75; return inputCost + outputCost; } // ========== Pending Hooks State Machine ========== // Track pending hook events per session to determine tab alerts. // Action hooks (permission_prompt, elicitation_dialog) take priority over idle_prompt. setPendingHook(sessionId, hookType) { if (!this.pendingHooks.has(sessionId)) { this.pendingHooks.set(sessionId, new Set()); } this.pendingHooks.get(sessionId).add(hookType); this.updateTabAlertFromHooks(sessionId); } clearPendingHooks(sessionId, hookType = null) { const hooks = this.pendingHooks.get(sessionId); if (!hooks) return; if (hookType) { hooks.delete(hookType); } else { hooks.clear(); } if (hooks.size === 0) { this.pendingHooks.delete(sessionId); } this.updateTabAlertFromHooks(sessionId); } updateTabAlertFromHooks(sessionId) { const hooks = this.pendingHooks.get(sessionId); if (!hooks || hooks.size === 0) { this.tabAlerts.delete(sessionId); } else if (hooks.has('permission_prompt') || hooks.has('elicitation_dialog')) { this.tabAlerts.set(sessionId, 'action'); } else if (hooks.has('idle_prompt')) { this.tabAlerts.set(sessionId, 'idle'); } this.renderSessionTabs(); } init() { this.initTerminal(); this.loadFontSize(); this.applyHeaderVisibilitySettings(); this.applyMonitorVisibility(); this.connectSSE(); this.loadState(); this.loadQuickStartCases(); this.setupEventListeners(); // Start system stats polling this.startSystemStatsPolling(); // Load server-stored settings (async, re-applies visibility after load) this.loadAppSettingsFromServer().then(() => { this.applyHeaderVisibilitySettings(); this.applyMonitorVisibility(); }); } initTerminal() { // Load scrollback setting from localStorage (default 5000) const scrollback = parseInt(localStorage.getItem('claudeman-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', fontSize: 14, lineHeight: 1.2, cursorBlink: true, cursorStyle: 'block', scrollback: scrollback, allowTransparency: true, }); this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); const container = document.getElementById('terminalContainer'); this.terminal.open(container); 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 }); // Welcome message this.showWelcome(); // Handle resize with throttling for performance this._resizeTimeout = null; this._lastResizeDims = null; const throttledResize = () => { if (this._resizeTimeout) return; this._resizeTimeout = setTimeout(() => { this._resizeTimeout = null; if (this.fitAddon) { this.fitAddon.fit(); if (this.activeSessionId) { const dims = this.fitAddon.proposeDimensions(); // Only send resize if dimensions actually changed if (dims && (!this._lastResizeDims || dims.cols !== this._lastResizeDims.cols || dims.rows !== this._lastResizeDims.rows)) { this._lastResizeDims = { cols: dims.cols, rows: dims.rows }; fetch(`/api/sessions/${this.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } } } // Update subagent connection lines when viewport resizes this.updateConnectionLines(); }, 100); // Throttle to 100ms }; window.addEventListener('resize', throttledResize); const resizeObserver = new ResizeObserver(throttledResize); resizeObserver.observe(container); // Handle keyboard input with batching for rapid keystrokes this._pendingInput = ''; this._inputFlushTimeout = null; this._inputFlushDelay = 16; // Flush at 60fps max const flushInput = () => { if (this._pendingInput && this.activeSessionId) { const input = this._pendingInput; const sessionId = this.activeSessionId; this._pendingInput = ''; fetch(`/api/sessions/${sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input }) }); // Clear pending hooks when user sends input (they've addressed the prompt) this.clearPendingHooks(sessionId); } this._inputFlushTimeout = null; }; this.terminal.onData((data) => { if (this.activeSessionId) { this._pendingInput += data; // Flush immediately for control characters (Enter, Ctrl+C, etc.) if (data.charCodeAt(0) < 32 || data.length > 1) { if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } flushInput(); return; } // Batch regular input at 60fps if (!this._inputFlushTimeout) { this._inputFlushTimeout = setTimeout(flushInput, this._inputFlushDelay); } } }); } /** * 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() { this.terminal.writeln('\x1b[1;36m Claudeman Terminal\x1b[0m'); this.terminal.writeln(''); this.terminal.writeln('\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m'); this.terminal.writeln('\x1b[90m Sessions stay alive for autonomous work, even if you close this browser.\x1b[0m'); this.terminal.writeln('\x1b[90m Use the +/- controls to set how many instances to launch at once.\x1b[0m'); this.terminal.writeln(''); this.terminal.writeln('\x1b[90m Press \x1b[1;37mCtrl+Enter\x1b[0m\x1b[90m to start Claude\x1b[0m'); this.terminal.writeln(''); } /** * 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) { // 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(); } // Accumulate raw data (may contain DEC 2026 markers) this.pendingWrites += data; if (!this.writeFrameScheduled) { this.writeFrameScheduled = true; requestAnimationFrame(() => { if (this.pendingWrites && this.terminal) { // Check if we have an incomplete sync block (SYNC_START without SYNC_END) const hasStart = this.pendingWrites.includes(DEC_SYNC_START); const hasEnd = this.pendingWrites.includes(DEC_SYNC_END); if (hasStart && !hasEnd) { // Incomplete sync block - wait for more data (up to 50ms max) if (!this.syncWaitTimeout) { this.syncWaitTimeout = setTimeout(() => { this.syncWaitTimeout = null; // Force flush after timeout to prevent stuck state this.flushPendingWrites(); }, 50); } this.writeFrameScheduled = false; return; } // Clear any pending sync wait timeout if (this.syncWaitTimeout) { clearTimeout(this.syncWaitTimeout); this.syncWaitTimeout = null; } this.flushPendingWrites(); } this.writeFrameScheduled = false; }); } } /** * Flush pending writes to terminal, processing DEC 2026 sync markers. * Strips markers and writes content atomically within a single frame. */ flushPendingWrites() { if (!this.pendingWrites || !this.terminal) return; // Extract segments, stripping DEC 2026 markers // This implements synchronized output for xterm.js which doesn't support DEC 2026 natively const segments = extractSyncSegments(this.pendingWrites); this.pendingWrites = ''; // Write all segments in a single batch (atomic within this frame) // xterm.js internally batches multiple write() calls within same frame for (const segment of segments) { if (segment && !segment.startsWith(DEC_SYNC_START)) { this.terminal.write(segment); } } // Sticky scroll: if user was at bottom, keep them there after new output if (this._wasAtBottomBeforeWrite) { this.terminal.scrollToBottom(); } } /** * 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 64KB for smooth 60fps) * @returns {Promise} - Resolves when all chunks written */ chunkedTerminalWrite(buffer, chunkSize = 64 * 1024) { return new Promise((resolve) => { if (!buffer || buffer.length === 0) { resolve(); return; } // Strip any DEC 2026 markers that might be in the buffer // (from historical SSE data that was stored with markers) const cleanBuffer = buffer .replaceAll(DEC_SYNC_START, '') .replaceAll(DEC_SYNC_END, ''); // For small buffers, write directly if (cleanBuffer.length <= chunkSize) { this.terminal.write(cleanBuffer); resolve(); return; } let offset = 0; const writeChunk = () => { if (offset >= cleanBuffer.length) { // Wait one more frame for xterm to finish rendering before resolving requestAnimationFrame(() => resolve()); return; } const chunk = cleanBuffer.slice(offset, offset + chunkSize); this.terminal.write(chunk); offset += chunkSize; // Schedule next chunk on next frame requestAnimationFrame(writeChunk); }; // Start writing requestAnimationFrame(writeChunk); }); } setupEventListeners() { // Use capture to handle before terminal document.addEventListener('keydown', (e) => { // Escape - close panels and modals if (e.key === 'Escape') { this.closeAllPanels(); this.closeHelp(); } // Ctrl/Cmd + ? - help if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) { e.preventDefault(); this.showHelp(); } // Ctrl/Cmd + Enter - quick start if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); this.quickStart(); } // Ctrl/Cmd + W - close active session if ((e.ctrlKey || e.metaKey) && e.key === 'w') { e.preventDefault(); this.killActiveSession(); } // Ctrl/Cmd + Tab - next session if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') { e.preventDefault(); this.nextSession(); } // Ctrl/Cmd + K - kill all if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); this.killAllSessions(); } // Ctrl/Cmd + L - clear terminal if ((e.ctrlKey || e.metaKey) && e.key === 'l') { e.preventDefault(); this.clearTerminal(); } // Ctrl/Cmd + +/- - font size if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.increaseFontSize(); } if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); this.decreaseFontSize(); } }, true); // Use capture phase to handle before terminal // Token stats click handler const tokenEl = this.$('headerTokens'); if (tokenEl) { tokenEl.classList.add('clickable'); tokenEl.addEventListener('click', () => this.openTokenStats()); } } // ========== SSE Connection ========== connectSSE() { // Clear any pending reconnect timeout to prevent duplicate connections if (this.sseReconnectTimeout) { clearTimeout(this.sseReconnectTimeout); this.sseReconnectTimeout = null; } // Clean up existing SSE listeners before creating new connection (prevents listener accumulation) if (this._sseListenerCleanup) { this._sseListenerCleanup(); this._sseListenerCleanup = null; } // Close existing EventSource before creating new one to prevent duplicate connections if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.eventSource = new EventSource('/api/events'); // Store all event listeners for cleanup on reconnect const listeners = []; const addListener = (event, handler) => { this.eventSource.addEventListener(event, handler); listeners.push({ event, handler }); }; // Create cleanup function to remove all listeners this._sseListenerCleanup = () => { for (const { event, handler } of listeners) { if (this.eventSource) { this.eventSource.removeEventListener(event, handler); } } listeners.length = 0; }; this.eventSource.onopen = () => this.setConnectionStatus('connected'); this.eventSource.onerror = () => { this.setConnectionStatus('disconnected'); // Close the failed connection before scheduling reconnect if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } // Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts) if (this.sseReconnectTimeout) { clearTimeout(this.sseReconnectTimeout); } this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), 3000); }; addListener('init', (e) => { try { this.handleInit(JSON.parse(e.data)); } catch (err) { console.error('[SSE] Failed to parse init event:', err); } }); addListener('session:created', (e) => { const data = JSON.parse(e.data); this.sessions.set(data.id, data); this.renderSessionTabs(); this.updateCost(); }); addListener('session:updated', (e) => { const data = JSON.parse(e.data); const session = data.session || data; this.sessions.set(session.id, session); this.renderSessionTabs(); this.updateCost(); // Update tokens display if this is the active session if (session.id === this.activeSessionId && session.tokens) { this.updateRespawnTokens(session.tokens); } // Update parentSessionName for any subagents belonging to this session // (fixes stale name display after session rename) this.updateSubagentParentNames(session.id); }); addListener('session:deleted', (e) => { const data = JSON.parse(e.data); this.sessions.delete(data.id); this.terminalBuffers.delete(data.id); this.ralphStates.delete(data.id); // Clean up ralph state for this session this.projectInsights.delete(data.id); // Clean up project insights for this session this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session this.closeSessionImagePopups(data.id); // Close image popup windows for this session this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data // Clean up idle timer for this session const idleTimer = this.idleTimers.get(data.id); if (idleTimer) { clearTimeout(idleTimer); this.idleTimers.delete(data.id); } // Clean up respawn state for this session delete this.respawnStatus[data.id]; delete this.respawnTimers[data.id]; delete this.respawnCountdownTimers[data.id]; delete this.respawnActionLogs[data.id]; if (this.activeSessionId === data.id) { this.activeSessionId = null; this.terminal.clear(); this.showWelcome(); } this.renderSessionTabs(); this.renderRalphStatePanel(); // Update ralph panel after session deleted this.renderProjectInsightsPanel(); // Update project insights panel after session deleted }); addListener('session:terminal', (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { this.batchTerminalWrite(data.data); } }); addListener('session:clearTerminal', async (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { // Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed) try { const res = await fetch(`/api/sessions/${data.id}/terminal`); const termData = await res.json(); this.terminal.clear(); this.terminal.reset(); if (termData.terminalBuffer) { // Strip any DEC 2026 markers and write raw content // (markers don't help here - this is a static buffer reload, not live Ink redraws) const cleanBuffer = termData.terminalBuffer .replaceAll(DEC_SYNC_START, '') .replaceAll(DEC_SYNC_END, ''); // Use chunked write to avoid UI freeze with large buffers (can be 1-2MB) await this.chunkedTerminalWrite(cleanBuffer); } // Send resize to ensure proper dimensions const dims = this.fitAddon.proposeDimensions(); if (dims) { await fetch(`/api/sessions/${data.id}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } } catch (err) { console.error('clearTerminal refresh failed:', err); } } }); addListener('session:completion', (e) => { const data = JSON.parse(e.data); this.totalCost += data.cost || 0; this.updateCost(); if (data.id === this.activeSessionId) { this.terminal.writeln(''); this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`); } }); addListener('session:error', (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`); } const session = this.sessions.get(data.id); this.notificationManager?.notify({ urgency: 'critical', category: 'session-error', sessionId: data.id, sessionName: session?.name || data.id?.slice(0, 8), title: 'Session Error', message: data.error || 'Unknown error', }); }); addListener('session:exit', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.id); if (session) { session.status = 'stopped'; this.renderSessionTabs(); } // Notify on unexpected exit (non-zero code) if (data.code && data.code !== 0) { this.notificationManager?.notify({ urgency: 'critical', category: 'session-crash', sessionId: data.id, sessionName: session?.name || data.id?.slice(0, 8), title: 'Session Crashed', message: `Exited with code ${data.code}`, }); } }); addListener('session:idle', (e) => { const data = JSON.parse(e.data); console.log('[DEBUG] session:idle event for:', data.id); const session = this.sessions.get(data.id); if (session) { session.status = 'idle'; this.renderSessionTabs(); this.sendPendingCtrlL(data.id); } // Start stuck detection timer (only if no respawn running) if (!this.respawnStatus[data.id]?.enabled) { const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000; clearTimeout(this.idleTimers.get(data.id)); this.idleTimers.set(data.id, setTimeout(() => { const s = this.sessions.get(data.id); this.notificationManager?.notify({ urgency: 'warning', category: 'session-stuck', sessionId: data.id, sessionName: s?.name || data.id?.slice(0, 8), title: 'Session Idle', message: `Idle for ${Math.round(threshold / 60000)}+ minutes`, }); this.idleTimers.delete(data.id); }, threshold)); } }); addListener('session:working', (e) => { const data = JSON.parse(e.data); console.log('[DEBUG] session:working event for:', data.id); const session = this.sessions.get(data.id); if (session) { session.status = 'busy'; // Only clear tab alert if no pending hooks (permission_prompt, elicitation_dialog, etc.) if (!this.pendingHooks.has(data.id)) { this.tabAlerts.delete(data.id); } this.renderSessionTabs(); this.sendPendingCtrlL(data.id); } // Clear stuck detection timer const timer = this.idleTimers.get(data.id); if (timer) { clearTimeout(timer); this.idleTimers.delete(data.id); } }); // Scheduled run events addListener('scheduled:created', (e) => { this.currentRun = JSON.parse(e.data); this.showTimer(); }); addListener('scheduled:updated', (e) => { this.currentRun = JSON.parse(e.data); this.updateTimer(); }); addListener('scheduled:completed', (e) => { this.currentRun = JSON.parse(e.data); this.hideTimer(); this.showToast('Scheduled run completed!', 'success'); }); addListener('scheduled:stopped', (e) => { this.currentRun = null; this.hideTimer(); }); // Respawn events addListener('respawn:started', (e) => { const data = JSON.parse(e.data); this.respawnStatus[data.sessionId] = data.status; if (data.sessionId === this.activeSessionId) { this.showRespawnBanner(); } }); addListener('respawn:stopped', (e) => { const data = JSON.parse(e.data); delete this.respawnStatus[data.sessionId]; if (data.sessionId === this.activeSessionId) { this.hideRespawnBanner(); } }); addListener('respawn:stateChanged', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].state = data.state; } if (data.sessionId === this.activeSessionId) { this.updateRespawnBanner(data.state); } }); addListener('respawn:cycleStarted', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber; } if (data.sessionId === this.activeSessionId) { document.getElementById('respawnCycleCount').textContent = data.cycleNumber; } }); addListener('respawn:blocked', (e) => { const data = JSON.parse(e.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 || data.sessionId?.slice(0, 8), 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'); } } }); addListener('respawn:stepSent', (_e) => { // Step info is shown via state label (e.g., "Sending prompt", "Clearing context") }); addListener('respawn:autoAcceptSent', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.sessionId); this.notificationManager?.notify({ urgency: 'info', category: 'auto-accept', sessionId: data.sessionId, sessionName: session?.name || data.sessionId?.slice(0, 8), title: 'Plan Accepted', message: `Accepted plan mode for ${session?.name || 'session'}`, }); }); addListener('respawn:detectionUpdate', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].detection = data.detection; } if (data.sessionId === this.activeSessionId) { this.updateDetectionDisplay(data.detection); } }); addListener('respawn:aiCheckStarted', (_e) => { // AI check status shown via updateDetectionDisplay }); addListener('respawn:aiCheckCompleted', (_e) => { // AI check status shown via updateDetectionDisplay }); addListener('respawn:aiCheckFailed', (_e) => { // AI check status shown via updateDetectionDisplay }); addListener('respawn:aiCheckCooldown', (_e) => { // AI check status shown via updateDetectionDisplay }); // Respawn run timer events (timed respawn runs) addListener('respawn:timerStarted', (e) => { const data = JSON.parse(e.data); this.respawnTimers[data.sessionId] = { endAt: data.endAt, startedAt: data.startedAt, durationMinutes: data.durationMinutes }; if (data.sessionId === this.activeSessionId) { this.showRespawnTimer(); } }); // Respawn controller countdown timer events (internal timers) addListener('respawn:timerStarted', (e) => { const data = JSON.parse(e.data); // This may fire for both run timers and controller timers - check for timer object 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(); } } }); addListener('respawn:timerCancelled', (e) => { const data = JSON.parse(e.data); const { sessionId, timerName } = data; if (this.respawnCountdownTimers[sessionId]) { delete this.respawnCountdownTimers[sessionId][timerName]; } if (sessionId === this.activeSessionId) { this.updateCountdownTimerDisplay(); } }); addListener('respawn:timerCompleted', (e) => { const data = JSON.parse(e.data); const { sessionId, timerName } = data; if (this.respawnCountdownTimers[sessionId]) { delete this.respawnCountdownTimers[sessionId][timerName]; } if (sessionId === this.activeSessionId) { this.updateCountdownTimerDisplay(); } }); addListener('respawn:actionLog', (e) => { const data = JSON.parse(e.data); const { sessionId, action } = data; this.addActionLogEntry(sessionId, action); if (sessionId === this.activeSessionId) { this.updateCountdownTimerDisplay(); // Show row if hidden this.updateActionLogDisplay(); } }); // Auto-clear event addListener('session:autoClear', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info'); this.updateRespawnTokens(0); } const session = this.sessions.get(data.sessionId); this.notificationManager?.notify({ urgency: 'info', category: 'auto-clear', sessionId: data.sessionId, sessionName: session?.name || data.sessionId?.slice(0, 8), title: 'Auto-Cleared', message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`, }); }); // Background task events addListener('task:created', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); addListener('task:completed', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); addListener('task:failed', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); addListener('task:updated', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); // Screen events addListener('screen:created', (e) => { const screen = JSON.parse(e.data); this.screenSessions.push(screen); this.renderScreenSessions(); }); addListener('screen:killed', (e) => { const data = JSON.parse(e.data); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.renderScreenSessions(); }); addListener('screen:died', (e) => { const data = JSON.parse(e.data); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.renderScreenSessions(); this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning'); }); addListener('screen:statsUpdated', (e) => { this.screenSessions = JSON.parse(e.data); if (document.getElementById('monitorPanel').classList.contains('open')) { this.renderScreenSessions(); } }); // Ralph loop/todo events addListener('session:ralphLoopUpdate', (e) => { const data = JSON.parse(e.data); this.updateRalphState(data.sessionId, { loop: data.state }); }); addListener('session:ralphTodoUpdate', (e) => { const data = JSON.parse(e.data); this.updateRalphState(data.sessionId, { todos: data.todos }); }); addListener('session:ralphCompletionDetected', (e) => { const data = JSON.parse(e.data); // 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 || data.sessionId?.slice(0, 8), title: 'Loop Complete', message: `Completion: ${data.phrase || 'unknown'}`, }); }); // RALPH_STATUS block and circuit breaker events addListener('session:ralphStatusUpdate', (e) => { const data = JSON.parse(e.data); this.updateRalphState(data.sessionId, { statusBlock: data.block }); }); addListener('session:circuitBreakerUpdate', (e) => { const data = JSON.parse(e.data); 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 || data.sessionId?.slice(0, 8), title: 'Circuit Breaker Open', message: data.status.reason || 'Loop stuck - no progress detected', }); } }); addListener('session:exitGateMet', (e) => { const data = JSON.parse(e.data); // Notify when exit gate is met const session = this.sessions.get(data.sessionId); this.notificationManager?.notify({ urgency: 'warning', category: 'exit-gate', sessionId: data.sessionId, sessionName: session?.name || data.sessionId?.slice(0, 8), title: 'Exit Gate Met', message: `Loop ready to exit (indicators: ${data.completionIndicators})`, }); }); // Active Bash tool events (for clickable file paths) addListener('session:bashToolStart', (e) => { const data = JSON.parse(e.data); this.handleBashToolStart(data.sessionId, data.tool); }); addListener('session:bashToolEnd', (e) => { const data = JSON.parse(e.data); this.handleBashToolEnd(data.sessionId, data.tool); }); addListener('session:bashToolsUpdate', (e) => { const data = JSON.parse(e.data); this.handleBashToolsUpdate(data.sessionId, data.tools); }); // Spawn agent notification events addListener('spawn:failed', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'critical', category: 'spawn-failed', sessionId: data.sessionId, sessionName: data.agentId || 'agent', title: 'Agent Failed', message: `Agent "${data.agentId}" failed: ${data.reason || 'unknown'}`, }); }); addListener('spawn:timeout', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'critical', category: 'spawn-timeout', sessionId: data.sessionId, sessionName: data.agentId || 'agent', title: 'Agent Timeout', message: `Agent "${data.agentId}" exceeded time limit`, }); }); addListener('spawn:budgetWarning', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'warning', category: 'spawn-budget', sessionId: data.sessionId, sessionName: data.agentId || 'agent', title: 'Budget Warning', message: `Agent "${data.agentId}" at ${data.percent || 80}% budget`, }); }); addListener('spawn:completed', (e) => { const data = JSON.parse(e.data); this.notificationManager?.notify({ urgency: 'info', category: 'spawn-completed', sessionId: data.sessionId, sessionName: data.agentId || 'agent', title: 'Agent Complete', message: `Agent "${data.agentId}" finished successfully`, }); }); // Hook events (from Claude Code hooks system) // Use pendingHooks state machine to track hook events and derive tab alerts. // This ensures alerts persist even when session:working events fire. addListener('hook:idle_prompt', (e) => { const data = JSON.parse(e.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', }); }); addListener('hook:permission_prompt', (e) => { const data = JSON.parse(e.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', }); }); addListener('hook:elicitation_dialog', (e) => { const data = JSON.parse(e.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', }); }); addListener('hook:stop', (e) => { const data = JSON.parse(e.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', }); }); // ========== Subagent Events (Claude Code Background Agents) ========== addListener('subagent:discovered', async (e) => { const data = JSON.parse(e.data); this.subagents.set(data.agentId, data); this.subagentActivity.set(data.agentId, []); this.renderSubagentPanel(); // Find which Claudeman session owns this subagent (by working directory) await this.findParentSessionForSubagent(data.agentId); // Auto-open window for new active agents if (data.status === 'active') { this.openSubagentWindow(data.agentId); } }); addListener('subagent:updated', (e) => { const data = JSON.parse(e.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); } }); addListener('subagent:tool_call', (e) => { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'tool', ...data }); if (activity.length > 100) activity.shift(); // Keep last 100 entries this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } this.renderSubagentPanel(); // Update floating window if (this.subagentWindows.has(data.agentId)) { this.renderSubagentWindowContent(data.agentId); } }); addListener('subagent:progress', (e) => { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'progress', ...data }); if (activity.length > 100) activity.shift(); this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } // Update floating window if (this.subagentWindows.has(data.agentId)) { this.renderSubagentWindowContent(data.agentId); } }); addListener('subagent:message', (e) => { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'message', ...data }); if (activity.length > 100) activity.shift(); this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } // Update floating window if (this.subagentWindows.has(data.agentId)) { this.renderSubagentWindowContent(data.agentId); } }); addListener('subagent:tool_result', (e) => { const data = JSON.parse(e.data); // Store tool result by toolUseId for later lookup if (!this.subagentToolResults.has(data.agentId)) { this.subagentToolResults.set(data.agentId, new Map()); } this.subagentToolResults.get(data.agentId).set(data.toolUseId, data); // Add to activity stream const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'tool_result', ...data }); if (activity.length > 100) activity.shift(); this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } // Update floating window if (this.subagentWindows.has(data.agentId)) { this.renderSubagentWindowContent(data.agentId); } }); addListener('subagent:completed', async (e) => { const data = JSON.parse(e.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 } } // 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 }); // ========== Image Detection Events (Screenshots & Generated Images) ========== addListener('image:detected', (e) => { const data = JSON.parse(e.data); console.log('[Image Detected]', data); this.openImagePopup(data); }); // Plan subagent visibility events (show Opus agents during plan generation) addListener('plan:subagent', (e) => { const data = JSON.parse(e.data); console.log('[Plan Subagent]', data); this.handlePlanSubagentEvent(data); }); // Plan generation progress events (for detailed mode with subagents) addListener('plan:progress', (e) => { const data = JSON.parse(e.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; } }); // Plan generation started - store orchestrator ID for cancellation addListener('plan:started', (e) => { const data = JSON.parse(e.data); console.log('[Plan Started]', data); this.activePlanOrchestratorId = data.orchestratorId; this.planGenerationStopped = false; // Reset flag for new generation this.renderMonitorPlanAgents(); }); // Plan generation cancelled addListener('plan:cancelled', (e) => { const data = JSON.parse(e.data); console.log('[Plan Cancelled]', data); if (this.activePlanOrchestratorId === data.orchestratorId) { this.activePlanOrchestratorId = null; } this.renderMonitorPlanAgents(); }); // Plan generation completed addListener('plan:completed', (e) => { const data = JSON.parse(e.data); console.log('[Plan Completed]', data); if (this.activePlanOrchestratorId === data.orchestratorId) { this.activePlanOrchestratorId = null; } this.renderMonitorPlanAgents(); }); } setConnectionStatus(status) { const dot = document.querySelector('.status-dot'); const text = document.querySelector('.status-text'); dot.className = 'status-dot ' + status; text.textContent = status === 'connected' ? 'Connected' : 'Disconnected'; } handleInit(data) { // Update version display if (data.version) { const versionEl = this.$('versionDisplay'); if (versionEl) { versionEl.textContent = `v${data.version}`; versionEl.title = `Claudeman v${data.version}`; } } this.sessions.clear(); this.ralphStates.clear(); this.terminalBuffers.clear(); this.projectInsights.clear(); // Clear all idle timers to prevent stale timers from firing for (const timer of this.idleTimers.values()) { clearTimeout(timer); } this.idleTimers.clear(); // Clear pending hooks this.pendingHooks.clear(); // Clear tab alerts this.tabAlerts.clear(); // Clear shown completions (used for duplicate notification prevention) if (this._shownCompletions) { this._shownCompletions.clear(); } // Clear notification manager title flash interval to prevent memory leak if (this.notificationManager?.titleFlashInterval) { clearInterval(this.notificationManager.titleFlashInterval); this.notificationManager.titleFlashInterval = null; } // Clear any other orphaned timers if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); this.planLoadingTimer = null; } if (this.timerCountdownInterval) { clearInterval(this.timerCountdownInterval); this.timerCountdownInterval = null; } if (this.runSummaryAutoRefreshTimer) { clearInterval(this.runSummaryAutoRefreshTimer); this.runSummaryAutoRefreshTimer = null; } data.sessions.forEach(s => { this.sessions.set(s.id, s); // Load ralph state from session data if (s.ralphLoop || s.ralphTodos) { this.ralphStates.set(s.id, { loop: s.ralphLoop || null, todos: s.ralphTodos || [] }); } }); if (data.respawnStatus) { this.respawnStatus = data.respawnStatus; } else { // Clear respawn status on init if not provided (prevents stale data) this.respawnStatus = {}; } // Clean up respawn state for sessions that no longer exist this.respawnTimers = {}; this.respawnCountdownTimers = {}; this.respawnActionLogs = {}; // Store global stats for aggregate tracking if (data.globalStats) { this.globalStats = data.globalStats; } this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0); this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0); const activeRun = data.scheduledRuns.find(r => r.status === 'running'); if (activeRun) { this.currentRun = activeRun; this.showTimer(); } this.updateCost(); this.renderSessionTabs(); // CRITICAL: Clean up all floating windows before loading new subagents // This prevents memory leaks from ResizeObservers, EventSources, and DOM elements this.cleanupAllFloatingWindows(); // Load subagents - clear all related maps to prevent memory leaks on reconnect if (data.subagents) { this.subagents.clear(); this.subagentActivity.clear(); this.subagentToolResults.clear(); data.subagents.forEach(s => { this.subagents.set(s.agentId, s); }); this.renderSubagentPanel(); // Restore subagent window states (minimized/open) after subagents are loaded this.restoreSubagentWindowStates(); } // Auto-select first session if any if (this.sessions.size > 0 && !this.activeSessionId) { const firstSession = this.sessions.values().next().value; this.selectSession(firstSession.id); } } async loadState() { try { const res = await fetch('/api/status'); const data = await res.json(); this.handleInit(data); } catch (err) { console.error('Failed to load state:', err); } } // ========== Session Tabs ========== renderSessionTabs() { // Debounce renders at 100ms to prevent excessive DOM updates if (this.renderSessionTabsTimeout) { clearTimeout(this.renderSessionTabsTimeout); } this.renderSessionTabsTimeout = setTimeout(() => { this._renderSessionTabsImmediate(); }, 100); } _renderSessionTabsImmediate() { const container = this.$('sessionTabs'); const existingTabs = container.querySelectorAll('.session-tab[data-id]'); const existingIds = new Set([...existingTabs].map(t => t.dataset.id)); const currentIds = new Set(this.sessions.keys()); // Check if we can do incremental update (same session IDs) const canIncremental = existingIds.size === currentIds.size && [...existingIds].every(id => currentIds.has(id)); if (canIncremental) { // Incremental update - only modify changed properties for (const [id, session] of this.sessions) { const tab = container.querySelector(`.session-tab[data-id="${id}"]`); if (!tab) continue; const isActive = id === this.activeSessionId; const status = session.status || 'idle'; const name = this.getSessionName(session); const taskStats = session.taskStats || { running: 0, total: 0 }; const hasRunningTasks = taskStats.running > 0; // Update active class if (isActive && !tab.classList.contains('active')) { tab.classList.add('active'); } else if (!isActive && tab.classList.contains('active')) { tab.classList.remove('active'); } // Update alert class const alertType = this.tabAlerts.get(id); const wantAction = alertType === 'action'; const wantIdle = alertType === 'idle'; const hasAction = tab.classList.contains('tab-alert-action'); const hasIdle = tab.classList.contains('tab-alert-idle'); if (wantAction && !hasAction) { tab.classList.add('tab-alert-action'); tab.classList.remove('tab-alert-idle'); } else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); } else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); } // Update status indicator const statusEl = tab.querySelector('.tab-status'); if (statusEl && !statusEl.classList.contains(status)) { statusEl.className = `tab-status ${status}`; } // Update name if changed const nameEl = tab.querySelector('.tab-name'); if (nameEl && nameEl.textContent !== name) { nameEl.textContent = name; } // Update task badge const badgeEl = tab.querySelector('.tab-badge'); if (hasRunningTasks) { if (badgeEl) { if (badgeEl.textContent !== String(taskStats.running)) { badgeEl.textContent = taskStats.running; } } else { // Need to add badge - do full rebuild this._fullRenderSessionTabs(); return; } } else if (badgeEl) { // Need to remove badge - do full rebuild this._fullRenderSessionTabs(); return; } // Update subagent badge - check if count changed const subagentBadgeEl = tab.querySelector('.tab-subagent-badge'); const minimizedAgents = this.minimizedSubagents.get(id); const minimizedCount = minimizedAgents?.size || 0; const currentCount = subagentBadgeEl ? parseInt(subagentBadgeEl.querySelector('.subagent-count')?.textContent || '0') : 0; if (minimizedCount !== currentCount) { // Count changed - need full rebuild for dropdown update this._fullRenderSessionTabs(); return; } } } else { // Full rebuild needed (sessions added/removed) this._fullRenderSessionTabs(); } // Auto-focus: if there's exactly one session and none is active, select it if (this.sessions.size === 1 && !this.activeSessionId) { const [sessionId] = this.sessions.keys(); this.selectSession(sessionId); } } _fullRenderSessionTabs() { const container = this.$('sessionTabs'); // Clean up any orphaned dropdowns before re-rendering document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove()); this.cancelHideSubagentDropdown(); // Build tabs HTML using array for better string concatenation performance const parts = []; for (const [id, session] of this.sessions) { const isActive = id === this.activeSessionId; const status = session.status || 'idle'; const name = this.getSessionName(session); const mode = session.mode || 'claude'; const taskStats = session.taskStats || { running: 0, total: 0 }; const hasRunningTasks = taskStats.running > 0; const alertType = this.tabAlerts.get(id); const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : ''; // Get minimized subagents for this session const minimizedAgents = this.minimizedSubagents.get(id); const minimizedCount = minimizedAgents?.size || 0; const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : ''; parts.push(`
${mode === 'shell' ? 'sh' : ''} ${this.escapeHtml(name)} ${hasRunningTasks ? `${taskStats.running}` : ''} ${subagentBadge} ⚙ ×
`); } container.innerHTML = parts.join(''); // Update connection lines after tabs change (positions may have shifted) this.updateConnectionLines(); } // Render subagent badge with dropdown for minimized agents on a tab renderSubagentTabBadge(sessionId, minimizedAgents) { if (!minimizedAgents || minimizedAgents.size === 0) return ''; const agentItems = []; for (const agentId of minimizedAgents) { const agent = this.subagents.get(agentId); const displayName = agent?.description || agentId.substring(0, 12); const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName; const statusClass = agent?.status || 'idle'; agentItems.push(`
${this.escapeHtml(truncatedName)} ×
`); } // Compact badge - shows on hover, click to pin open const count = minimizedAgents.size; const label = count === 1 ? 'AGENT' : `AGENTS (${count})`; return ` ${label}
${agentItems.join('')}
`; } // Restore a minimized subagent window restoreMinimizedSubagent(agentId, sessionId) { // Remove from minimized set const minimizedAgents = this.minimizedSubagents.get(sessionId); if (minimizedAgents) { minimizedAgents.delete(agentId); if (minimizedAgents.size === 0) { this.minimizedSubagents.delete(sessionId); } } // Restore the window this.restoreSubagentWindow(agentId); // Re-render tabs to update badge this.renderSessionTabs(); // Persist the state change this.saveSubagentWindowStates(); } // Show subagent dropdown on hover showSubagentDropdown(badgeEl) { this.cancelHideSubagentDropdown(); const dropdown = badgeEl.querySelector('.subagent-dropdown'); if (!dropdown || dropdown.classList.contains('open')) return; // Close other dropdowns first document.querySelectorAll('.subagent-dropdown.open').forEach(d => { d.classList.remove('open', 'pinned'); if (d.parentElement === document.body && d._originalParent) { d._originalParent.appendChild(d); } }); // Move to body to escape clipping dropdown._originalParent = badgeEl; document.body.appendChild(dropdown); // Position below badge const rect = badgeEl.getBoundingClientRect(); dropdown.style.top = `${rect.bottom + 2}px`; dropdown.style.left = `${rect.left + rect.width / 2}px`; dropdown.style.transform = 'translateX(-50%)'; dropdown.classList.add('open'); } // Schedule hide after delay (allows moving mouse to dropdown) scheduleHideSubagentDropdown(badgeEl) { this._subagentHideTimeout = setTimeout(() => { const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') || document.querySelector('.subagent-dropdown.open'); if (dropdown && !dropdown.classList.contains('pinned')) { dropdown.classList.remove('open'); if (dropdown._originalParent) { dropdown._originalParent.appendChild(dropdown); } } }, 150); } // Cancel scheduled hide cancelHideSubagentDropdown() { if (this._subagentHideTimeout) { clearTimeout(this._subagentHideTimeout); this._subagentHideTimeout = null; } } // Pin dropdown open on click (stays until clicking outside) pinSubagentDropdown(badgeEl) { const dropdown = document.querySelector('.subagent-dropdown.open'); if (!dropdown) { this.showSubagentDropdown(badgeEl); return; } dropdown.classList.toggle('pinned'); if (dropdown.classList.contains('pinned')) { // Close on outside click const closeHandler = (e) => { if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) { dropdown.classList.remove('open', 'pinned'); if (dropdown._originalParent) { dropdown._originalParent.appendChild(dropdown); } document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } } // Legacy toggle for backwards compat toggleSubagentDropdown(badgeEl) { this.pinSubagentDropdown(badgeEl); } // Permanently close a minimized subagent (remove from DOM and minimized set) permanentlyCloseMinimizedSubagent(agentId, sessionId) { // Remove from minimized set const minimizedAgents = this.minimizedSubagents.get(sessionId); if (minimizedAgents) { minimizedAgents.delete(agentId); if (minimizedAgents.size === 0) { this.minimizedSubagents.delete(sessionId); } } // Force close the window (removes from DOM) this.forceCloseSubagentWindow(agentId); // Re-render tabs to update badge this.renderSessionTabs(); this.updateConnectionLines(); // Persist the state change this.saveSubagentWindowStates(); } getSessionName(session) { // Use custom name if set if (session.name) { return session.name; } // Fall back to directory name if (session.workingDir) { return session.workingDir.split('/').pop() || session.workingDir; } return session.id.slice(0, 8); } async selectSession(sessionId) { if (this.activeSessionId === sessionId) return; this.activeSessionId = sessionId; // Clear idle hooks on view, but keep action hooks until user interacts this.clearPendingHooks(sessionId, 'idle_prompt'); this.renderSessionTabs(); // Check if this is a restored session that needs to be attached const session = this.sessions.get(sessionId); // Track working directory for path normalization in Project Insights this.currentSessionWorkingDir = session?.workingDir || null; if (session && session.pid === null && session.status === 'idle') { // This is a restored session - attach to the existing screen try { await fetch(`/api/sessions/${sessionId}/interactive`, { method: 'POST' }); // Update local session state session.status = 'busy'; } catch (err) { console.error('Failed to attach to restored session:', err); } } // Load terminal buffer for this session // Use tail mode for faster initial load (256KB is enough for recent visible content) try { const tailSize = 256 * 1024; const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`); const data = await res.json(); this.terminal.clear(); this.terminal.reset(); if (data.terminalBuffer) { // Show truncation indicator if buffer was cut if (data.truncated) { this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n'); } // Use chunked write for large buffers to avoid UI jank await this.chunkedTerminalWrite(data.terminalBuffer); // Ensure terminal is scrolled to bottom after buffer load this.terminal.scrollToBottom(); } // Send resize and Ctrl+L to trigger Claude to redraw at correct size const dims = this.fitAddon.proposeDimensions(); if (dims) { await fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } // Update respawn banner if (this.respawnStatus[sessionId]) { this.showRespawnBanner(); this.updateRespawnBanner(this.respawnStatus[sessionId].state); document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0; // Update countdown timers and action log for this session this.updateCountdownTimerDisplay(); this.updateActionLogDisplay(); if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) { this.startCountdownInterval(); } } else { this.hideRespawnBanner(); this.stopCountdownInterval(); } // Update task panel if open const taskPanel = document.getElementById('taskPanel'); if (taskPanel && taskPanel.classList.contains('open')) { this.renderTaskPanel(); } // Update ralph state panel for this session const session = this.sessions.get(sessionId); if (session && (session.ralphLoop || session.ralphTodos)) { this.updateRalphState(sessionId, { loop: session.ralphLoop, todos: session.ralphTodos }); } this.renderRalphStatePanel(); // Update project insights panel for this session this.renderProjectInsightsPanel(); // Update subagent window visibility for active session this.updateSubagentWindowVisibility(); // Load file browser if enabled const settings = this.loadAppSettingsFromStorage(); if (settings.showFileBrowser) { const fileBrowserPanel = this.$('fileBrowserPanel'); if (fileBrowserPanel) { fileBrowserPanel.classList.add('visible'); this.loadFileBrowser(sessionId); } } this.terminal.focus(); this.terminal.scrollToBottom(); } catch (err) { console.error('Failed to load session terminal:', err); } } async closeSession(sessionId, killScreen = true) { try { await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' }); this.sessions.delete(sessionId); this.terminalBuffers.delete(sessionId); this.ralphStates.delete(sessionId); this.clearCountdownTimers(sessionId); if (this.activeSessionId === sessionId) { this.activeSessionId = null; // Select another session or show welcome if (this.sessions.size > 0) { const nextSession = this.sessions.values().next().value; this.selectSession(nextSession.id); } else { this.terminal.clear(); this.showWelcome(); this.renderRalphStatePanel(); // Clear ralph panel when no sessions } } this.renderSessionTabs(); if (killScreen) { this.showToast('Session closed and screen killed', 'success'); } else { this.showToast('Tab hidden, screen still running', 'info'); } } catch (err) { this.showToast('Failed to close session', 'error'); } } // Request confirmation before closing a session requestCloseSession(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; this.pendingCloseSessionId = sessionId; // Show session name in confirmation dialog const name = this.getSessionName(session); const sessionNameEl = document.getElementById('closeConfirmSessionName'); sessionNameEl.textContent = name; document.getElementById('closeConfirmModal').classList.add('active'); } cancelCloseSession() { this.pendingCloseSessionId = null; document.getElementById('closeConfirmModal').classList.remove('active'); } async confirmCloseSession(killScreen = true) { const sessionId = this.pendingCloseSessionId; this.cancelCloseSession(); if (sessionId) { await this.closeSession(sessionId, killScreen); } } nextSession() { const ids = Array.from(this.sessions.keys()); if (ids.length <= 1) return; const currentIndex = ids.indexOf(this.activeSessionId); const nextIndex = (currentIndex + 1) % ids.length; this.selectSession(ids[nextIndex]); } // ========== Navigation ========== goHome() { // Deselect active session and show welcome screen this.activeSessionId = null; this.terminal.clear(); this.showWelcome(); this.renderSessionTabs(); this.renderRalphStatePanel(); } // ========== Ralph Loop Wizard ========== // Wizard state ralphWizardStep = 1; ralphWizardConfig = { taskDescription: '', completionPhrase: 'COMPLETE', maxIterations: 10, caseName: 'testcase', enableRespawn: true, // Plan generation fields generatedPlan: null, // [{content, priority, enabled, id}] or null planGenerated: false, skipPlanGeneration: false, planDetailLevel: 'detailed', // 'brief', 'standard', 'detailed' }; planLoadingTimer = null; planLoadingStartTime = null; showRalphWizard() { // Reset wizard state this.ralphWizardStep = 1; this.ralphWizardConfig = { taskDescription: '', completionPhrase: 'COMPLETE', maxIterations: 10, caseName: document.getElementById('quickStartCase')?.value || 'testcase', enableRespawn: true, autoStart: false, // Auto-start loop after plan generation // Plan generation fields generatedPlan: null, planGenerated: false, skipPlanGeneration: false, planDetailLevel: 'detailed', // Existing plan detection existingPlan: null, // { todos, stats, content } from @fix_plan.md useExistingPlan: false, // User chose to use existing plan }; // Reset UI document.getElementById('ralphTaskDescription').value = ''; document.getElementById('ralphCompletionPhrase').value = 'COMPLETE'; this.selectIterationPreset(10); // Reset auto-start checkboxes (off by default) - there are two, one in step 2 and one in step 3 const autoStartEl = document.getElementById('ralphAutoStart'); const autoStartStep2El = document.getElementById('ralphAutoStartStep2'); if (autoStartEl) autoStartEl.checked = false; if (autoStartStep2El) autoStartStep2El.checked = false; // Populate case selector this.populateRalphCaseSelector(); // Reset plan generation UI this.resetPlanGenerationUI(); // Check for existing @fix_plan.md in selected case this.checkExistingFixPlan(); // Show wizard modal this.updateRalphWizardUI(); const modal = document.getElementById('ralphWizardModal'); modal.classList.add('active'); // Reset wizard position to center this.wizardPosition = null; this.planAgentsMinimized = false; const wizardContent = modal.querySelector('.modal-content'); if (wizardContent) { wizardContent.classList.remove('draggable'); wizardContent.style.left = ''; wizardContent.style.top = ''; } // Set up wizard dragging this.setupWizardDragging(); // Hide minimize button initially (no agents yet) this.updateWizardAgentCount(); document.getElementById('ralphTaskDescription').focus(); // Update connection lines to show wizard connections this.updateConnectionLines(); } closeRalphWizard() { const modal = document.getElementById('ralphWizardModal'); modal?.classList.remove('active'); // Clean up wizard drag listeners this.cleanupWizardDragging(); // Remove agents minimized tab if exists this.removeAgentsMinimizedTab(); // Hide minimized indicator this.hideWizardMinimizedIndicator(); // Reset wizard position this.wizardPosition = null; this.planAgentsMinimized = false; // === CRITICAL: Clean up any orphaned plan generation state === // Cancel any in-flight plan generation if (this.activePlanOrchestratorId) { console.log('[Wizard Close] Cancelling active plan orchestrator:', this.activePlanOrchestratorId); fetch('/api/cancel-plan-generation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orchestratorId: this.activePlanOrchestratorId }), }).catch(err => console.error('[Wizard Close] Failed to cancel plan:', err)); this.activePlanOrchestratorId = null; } // Abort any in-flight fetch request if (this.planGenerationAbortController) { this.planGenerationAbortController.abort(); this.planGenerationAbortController = null; } // Clear progress handler to prevent orphaned callbacks this._planProgressHandler = null; // Clear plan loading timers if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); this.planLoadingTimer = null; } if (this.planPhaseTimer) { clearTimeout(this.planPhaseTimer); this.planPhaseTimer = null; } // Mark generation as stopped to ignore any late SSE events this.planGenerationStopped = true; // Close all plan subagent windows this.closePlanSubagentWindows(); // Clear wizard minimized windows tracking this.wizardMinimizedWindows = null; // Update monitor panel to reflect changes this.renderMonitorPlanAgents(); // Update connection lines (wizard closed, revert to normal) this.updateConnectionLines(); } minimizeRalphWizard() { const modal = document.getElementById('ralphWizardModal'); modal?.classList.remove('active'); // Track windows minimized due to wizard minimize (so we can restore them) this.wizardMinimizedWindows = new Set(); // Minimize all plan subagent windows for (const [agentId, windowData] of this.planSubagents) { if (windowData.element && windowData.element.style.display !== 'none') { windowData.element.style.display = 'none'; this.wizardMinimizedWindows.add(`plan:${agentId}`); } } // Minimize all regular subagent windows for (const [agentId, windowData] of this.subagentWindows) { if (windowData.element && !windowData.minimized && windowData.element.style.display !== 'none') { windowData.element.style.display = 'none'; windowData.hiddenByWizard = true; this.wizardMinimizedWindows.add(`subagent:${agentId}`); } } // Show the minimized indicator this.showWizardMinimizedIndicator(); // Update connection lines (all hidden now) this.updateConnectionLines(); } restoreRalphWizard() { // Hide the minimized indicator this.hideWizardMinimizedIndicator(); // Show the modal again const modal = document.getElementById('ralphWizardModal'); modal?.classList.add('active'); // Restore windows that were minimized due to wizard minimize if (this.wizardMinimizedWindows) { for (const key of this.wizardMinimizedWindows) { if (key.startsWith('plan:')) { const agentId = key.slice(5); const windowData = this.planSubagents.get(agentId); if (windowData?.element) { windowData.element.style.display = ''; } } else if (key.startsWith('subagent:')) { const agentId = key.slice(9); const windowData = this.subagentWindows.get(agentId); if (windowData?.element && windowData.hiddenByWizard) { windowData.element.style.display = ''; windowData.hiddenByWizard = false; } } } this.wizardMinimizedWindows.clear(); } // Update connection lines this.updateConnectionLines(); } showWizardMinimizedIndicator() { const indicator = document.getElementById('wizardMinimizedIndicator'); if (!indicator) return; indicator.classList.add('visible'); // Start time counter if plan generation is active if (this.planLoadingStartTime) { this.wizardMinimizedTimer = setInterval(() => { const elapsed = Math.floor((Date.now() - this.planLoadingStartTime) / 1000); const timeEl = document.getElementById('wizardMinimizedTime'); if (timeEl) timeEl.textContent = `${elapsed}s`; }, 1000); } } hideWizardMinimizedIndicator() { const indicator = document.getElementById('wizardMinimizedIndicator'); if (indicator) indicator.classList.remove('visible'); if (this.wizardMinimizedTimer) { clearInterval(this.wizardMinimizedTimer); this.wizardMinimizedTimer = null; } } populateRalphCaseSelector() { const select = document.getElementById('ralphCaseSelect'); const quickStartSelect = document.getElementById('quickStartCase'); if (quickStartSelect && select) { select.innerHTML = quickStartSelect.innerHTML; select.value = this.ralphWizardConfig.caseName; } } selectIterationPreset(iterations) { this.ralphWizardConfig.maxIterations = iterations; // Update button states document.querySelectorAll('.iteration-preset-btn').forEach(btn => { const btnIterations = parseInt(btn.dataset.iterations); btn.classList.toggle('active', btnIterations === iterations); }); } // Check for existing @fix_plan.md in the selected case async checkExistingFixPlan() { const caseName = this.ralphWizardConfig.caseName; if (!caseName) return; try { const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`); const data = await res.json(); if (data.success && data.exists && data.todos?.length > 0) { this.ralphWizardConfig.existingPlan = { todos: data.todos, stats: data.stats, content: data.content, }; this.updateExistingPlanUI(); } else { this.ralphWizardConfig.existingPlan = null; this.updateExistingPlanUI(); } } catch (err) { console.error('Failed to check for existing plan:', err); this.ralphWizardConfig.existingPlan = null; } } // Check for existing ralph-wizard research files async checkExistingWizardFiles() { const caseName = this.ralphWizardConfig.caseName; if (!caseName) return; try { const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/ralph-wizard/files`); const data = await res.json(); if (data.success && data.data?.files?.length > 0) { this.ralphWizardConfig.existingWizardFiles = data.data.files; this.updateExistingWizardFilesUI(); } else { this.ralphWizardConfig.existingWizardFiles = null; this.updateExistingWizardFilesUI(); } } catch (err) { console.error('Failed to check for existing wizard files:', err); this.ralphWizardConfig.existingWizardFiles = null; } } // Update UI to show existing wizard files option updateExistingWizardFilesUI() { const files = this.ralphWizardConfig.existingWizardFiles; let section = document.getElementById('existingWizardFilesSection'); if (!files || files.length === 0) { section?.remove(); return; } // Count agents with results const agentsWithResults = files.filter(f => f.resultFile).length; if (!section) { // Create the section if it doesn't exist - insert before loading state const loadingState = document.getElementById('planGenerationLoading'); section = document.createElement('div'); section.id = 'existingWizardFilesSection'; section.className = 'existing-wizard-files-section'; loadingState?.parentNode?.insertBefore(section, loadingState); } section.innerHTML = `
📁 Previous Research Found

Found ${agentsWithResults} agent result${agentsWithResults !== 1 ? 's' : ''} from a previous plan generation.

${files.filter(f => f.resultFile).map(f => ` ${this.getAgentTypeLabel(f.agentType)} `).join('')}
`; } getAgentTypeLabel(agentType) { const labels = { research: 'Research', requirements: 'Requirements', architecture: 'Architecture', testing: 'Testing', risks: 'Risks', verification: 'Verification', execution: 'Execution', 'final-review': 'Review', }; return labels[agentType] || agentType; } async reuseExistingWizardFiles() { // Load the final result if it exists const caseName = this.ralphWizardConfig.caseName; try { const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/ralph-wizard/file/${encodeURIComponent('final-result.json')}`); const data = await res.json(); if (data.success && data.data?.parsed?.items) { // Use the existing plan items this.ralphWizardConfig.generatedPlan = data.data.parsed.items.map((item, idx) => ({ content: item.content || item.title || `Task ${idx + 1}`, priority: item.priority || 'P1', enabled: true, id: item.id || `reused-${Date.now()}-${idx}`, source: item.source, rationale: item.rationale, tddPhase: item.tddPhase, })); this.ralphWizardConfig.planGenerated = true; this.ralphWizardConfig.reusingExistingFiles = true; this.ralphWizardConfig.planCost = 0; // Hide the existing files section document.getElementById('existingWizardFilesSection')?.remove(); // Show the plan editor this.renderPlanEditor(); this.updateDetailLevelButtons(); } else { // No final result, just skip to generate with existing research this.ralphWizardConfig.reusingExistingFiles = true; this.generatePlan(); } } catch (err) { console.error('Failed to load existing results:', err); // Fall back to regenerating this.generatePlan(); } } regenerateAllWizardFiles() { // Clear the reuse flag and hide the section this.ralphWizardConfig.reusingExistingFiles = false; document.getElementById('existingWizardFilesSection')?.remove(); this.generatePlan(); } // Called when case selector changes onRalphCaseChange() { const caseName = document.getElementById('ralphCaseSelect')?.value; if (caseName) { this.ralphWizardConfig.caseName = caseName; this.ralphWizardConfig.existingPlan = null; this.ralphWizardConfig.useExistingPlan = false; this.ralphWizardConfig.existingWizardFiles = null; this.checkExistingFixPlan(); } } // Update UI to show existing plan indicator updateExistingPlanUI() { const existingPlanBadge = document.getElementById('existingPlanBadge'); const existingPlanSection = document.getElementById('existingPlanSection'); const plan = this.ralphWizardConfig.existingPlan; if (existingPlanBadge) { if (plan) { const pending = plan.stats?.pending || 0; const total = plan.stats?.total || 0; existingPlanBadge.textContent = `${pending}/${total} tasks remaining`; existingPlanBadge.style.display = ''; } else { existingPlanBadge.style.display = 'none'; } } if (existingPlanSection) { if (plan) { const pending = plan.stats?.pending || 0; const completed = plan.stats?.completed || 0; const total = plan.stats?.total || 0; existingPlanSection.innerHTML = `
📋 Existing @fix_plan.md found
${pending} pending ${completed} completed ${total} total
`; existingPlanSection.classList.remove('hidden'); } else { existingPlanSection.classList.add('hidden'); } } } // Use the existing @fix_plan.md useExistingPlan() { const plan = this.ralphWizardConfig.existingPlan; if (!plan) return; // Stop any ongoing plan generation this.stopPlanGeneration(); // Convert existing todos to generatedPlan format (only pending items) const pendingTodos = plan.todos.filter(t => t.status === 'pending' || t.status === 'in_progress'); this.ralphWizardConfig.generatedPlan = pendingTodos.map((todo, idx) => ({ content: todo.content, priority: todo.priority, enabled: true, id: `existing-${Date.now()}-${idx}`, })); this.ralphWizardConfig.planGenerated = true; this.ralphWizardConfig.useExistingPlan = true; this.ralphWizardConfig.planCost = 0; // No cost for existing plan this.renderPlanEditor(); this.updateDetailLevelButtons(); } // Sync auto-start checkboxes between step 2 and step 3 syncAutoStartCheckbox(sourceEl) { const step2El = document.getElementById('ralphAutoStartStep2'); const step3El = document.getElementById('ralphAutoStart'); const isChecked = sourceEl.checked; if (step2El) step2El.checked = isChecked; if (step3El) step3El.checked = isChecked; // Update config this.ralphWizardConfig.autoStart = isChecked; } // Stop any ongoing plan generation (abort fetch, clear timers, hide spinner) stopPlanGeneration() { // Abort ongoing fetch if (this.planGenerationAbortController) { this.planGenerationAbortController.abort(); this.planGenerationAbortController = null; } // Clear timers if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); this.planLoadingTimer = null; } if (this.planPhaseTimer) { clearInterval(this.planPhaseTimer); this.planPhaseTimer = null; } // Hide loading spinner document.getElementById('planGenerationLoading')?.classList.add('hidden'); } // Generate a new plan (ignore existing) generateNewPlan() { this.ralphWizardConfig.useExistingPlan = false; document.getElementById('existingPlanSection')?.classList.add('hidden'); this.generatePlan(); } ralphWizardNext() { if (this.ralphWizardStep === 1) { // Validate step 1 const taskDescription = document.getElementById('ralphTaskDescription').value.trim(); const completionPhrase = document.getElementById('ralphCompletionPhrase').value.trim() || 'COMPLETE'; const caseName = document.getElementById('ralphCaseSelect').value; if (!taskDescription) { this.showToast('Please enter a task description', 'error'); document.getElementById('ralphTaskDescription').focus(); return; } // Save config this.ralphWizardConfig.taskDescription = taskDescription; this.ralphWizardConfig.completionPhrase = completionPhrase.toUpperCase(); this.ralphWizardConfig.caseName = caseName; // Move to step 2 (plan generation) this.ralphWizardStep = 2; this.updateRalphWizardUI(); // Check for existing wizard files first, then existing plan this.checkExistingWizardFiles().then(() => { // If there's an existing plan (@fix_plan.md), show the option to use it if (this.ralphWizardConfig.existingPlan) { this.updateExistingPlanUI(); } else if (!this.ralphWizardConfig.existingWizardFiles?.length) { // No existing files, auto-start plan generation this.generatePlan(); } // If existingWizardFiles exists, the UI will show and wait for user choice }); } else if (this.ralphWizardStep === 2) { // Must have generated or skipped plan if (!this.ralphWizardConfig.planGenerated && !this.ralphWizardConfig.skipPlanGeneration) { this.showToast('Wait for plan generation or skip', 'warning'); return; } // Generate preview this.updateRalphPromptPreview(); // Move to step 3 (launch) this.ralphWizardStep = 3; this.updateRalphWizardUI(); } } ralphWizardBack() { if (this.ralphWizardStep === 3) { this.ralphWizardStep = 2; this.updateRalphWizardUI(); } else if (this.ralphWizardStep === 2) { this.ralphWizardStep = 1; this.updateRalphWizardUI(); } } updateRalphWizardUI() { const step = this.ralphWizardStep; // Update progress indicators document.querySelectorAll('.wizard-step').forEach(el => { const stepNum = parseInt(el.dataset.step); el.classList.toggle('active', stepNum === step); el.classList.toggle('completed', stepNum < step); }); // Show/hide pages (now 3 pages) document.getElementById('ralphWizardStep1').classList.toggle('hidden', step !== 1); document.getElementById('ralphWizardStep2').classList.toggle('hidden', step !== 2); document.getElementById('ralphWizardStep3').classList.toggle('hidden', step !== 3); // Show/hide buttons document.getElementById('ralphBackBtn').style.display = step === 1 ? 'none' : 'block'; document.getElementById('ralphNextBtn').style.display = step === 3 ? 'none' : 'block'; document.getElementById('ralphStartBtn').style.display = step === 3 ? 'block' : 'none'; } updateRalphPromptPreview() { const config = this.ralphWizardConfig; const preview = document.getElementById('ralphPromptPreview'); const hasPlan = config.generatedPlan && config.generatedPlan.filter(i => i.enabled).length > 0; // Build the formatted prompt (abbreviated for preview) let prompt = config.taskDescription; prompt += '\n\n---\n\n'; if (hasPlan) { prompt += '## Task Plan\n'; prompt += '📋 @fix_plan.md will be created with your task items\n\n'; } prompt += '## Iteration Protocol\n'; prompt += '• Check previous work • Make progress • Commit changes\n\n'; prompt += '## Completion Criteria\n'; prompt += `Output \`${config.completionPhrase}\` when done\n\n`; prompt += '## If Stuck\n'; prompt += 'Output `BLOCKED` with explanation'; // Show preview with highlighting const highlightedPrompt = prompt .replace(//g, '<promise>') .replace(/<\/promise>/g, '</promise>') .replace(/`([^`]+)`/g, '$1'); preview.innerHTML = highlightedPrompt; // Update summary document.getElementById('summaryPhrase').textContent = config.completionPhrase; document.getElementById('summaryIterations').textContent = config.maxIterations === 0 ? 'Unlimited' : config.maxIterations; document.getElementById('summaryCase').textContent = config.caseName; // Show plan status in summary if plan was generated const planSummary = document.getElementById('summaryPlan'); if (planSummary) { if (config.generatedPlan && config.generatedPlan.length > 0) { const enabledCount = config.generatedPlan.filter(i => i.enabled).length; planSummary.textContent = `${enabledCount} item${enabledCount !== 1 ? 's' : ''}`; planSummary.parentElement.style.display = ''; } else { planSummary.parentElement.style.display = 'none'; } } } // ========== Plan Generation ========== resetPlanGenerationUI() { // Hide all plan generation states document.getElementById('existingPlanSection')?.classList.add('hidden'); document.getElementById('planGenerationLoading')?.classList.add('hidden'); document.getElementById('planGenerationError')?.classList.add('hidden'); document.getElementById('planEditor')?.classList.add('hidden'); // Reset spinner visibility (in case it was hidden after "Done!") const spinnerEl = document.querySelector('.plan-spinner'); if (spinnerEl) spinnerEl.style.display = ''; // Reset stopped indicator const stoppedIndicator = document.getElementById('planStoppedIndicator'); if (stoppedIndicator) stoppedIndicator.style.display = 'none'; // Reset existing plan badge const badge = document.getElementById('existingPlanBadge'); if (badge) badge.style.display = 'none'; } async generatePlan() { const config = this.ralphWizardConfig; const isDetailed = config.planDetailLevel === 'detailed'; // Stop any existing generation first this.stopPlanGeneration(); // Reset stopped flag to allow new SSE events this.planGenerationStopped = false; // Create abort controller for this generation this.planGenerationAbortController = new AbortController(); // Show loading state, hide other sections document.getElementById('existingPlanSection')?.classList.add('hidden'); document.getElementById('planGenerationError')?.classList.add('hidden'); document.getElementById('planEditor')?.classList.add('hidden'); document.getElementById('planGenerationLoading')?.classList.remove('hidden'); // Different phases for detailed vs standard generation const standardPhases = [ { time: 0, title: 'Starting Opus 4.5...', hint: 'Initializing deep reasoning model' }, { time: 3, title: 'Analyzing task requirements...', hint: 'Understanding the scope and complexity' }, { time: 8, title: 'Identifying components...', hint: 'Breaking down into modules and features' }, { time: 15, title: 'Planning TDD approach...', hint: 'Designing test-first implementation strategy' }, { time: 25, title: 'Generating implementation steps...', hint: 'Creating detailed action items with tests' }, { time: 40, title: 'Adding verification checkpoints...', hint: 'Ensuring each phase has validation' }, { time: 55, title: 'Reviewing for completeness...', hint: 'Checking all requirements are covered' }, { time: 70, title: 'Finalizing plan...', hint: 'Organizing and prioritizing steps' }, { time: 90, title: 'Still working...', hint: 'Complex tasks take longer - hang tight!' }, ]; const detailedPhases = [ // Phase 0: Research (can take up to 10 minutes for complex tasks) { time: 0, title: 'Starting research agent...', hint: 'Gathering external resources and codebase context' }, { time: 30, title: 'Research agent working...', hint: 'Searching docs, GitHub repos, and analyzing codebase' }, { time: 60, title: 'Research continuing...', hint: 'Web search and codebase exploration in progress' }, { time: 120, title: 'Research agent deep diving...', hint: 'Complex tasks require thorough research' }, { time: 180, title: 'Research almost complete...', hint: 'Compiling findings and recommendations' }, // Phase 1: Parallel Analysis (after research completes) // Note: These times are fallbacks - real-time SSE updates override them { time: 300, title: 'Spawning analysis subagents...', hint: 'Starting 4 specialist agents in parallel' }, { time: 330, title: 'Subagents analyzing...', hint: 'Requirements, Architecture, Testing, Risk analysts working' }, { time: 400, title: 'Subagents completing...', hint: 'Collecting analysis results' }, // Phase 2+: Synthesis and verification { time: 450, title: 'Synthesizing results...', hint: 'Merging and deduplicating items' }, { time: 500, title: 'Running verification...', hint: 'Quality assurance and priority assignment' }, { time: 550, title: 'Optimizing execution...', hint: 'Planning parallelization for Claude Code' }, { time: 600, title: 'Final review...', hint: 'Holistic validation and gap detection' }, { time: 660, title: 'Still working...', hint: 'Complex tasks take longer - hang tight!' }, ]; const phases = isDetailed ? detailedPhases : standardPhases; // Start elapsed time and phase display this.planLoadingStartTime = Date.now(); const timeEl = document.getElementById('planLoadingTime'); const titleEl = document.getElementById('planLoadingTitle'); const hintEl = document.getElementById('planLoadingHint'); if (timeEl) timeEl.textContent = '0s'; if (titleEl) titleEl.textContent = phases[0].title; if (hintEl) hintEl.textContent = phases[0].hint; let currentPhaseIndex = 0; this.planLoadingTimer = setInterval(() => { const elapsed = Math.floor((Date.now() - this.planLoadingStartTime) / 1000); if (timeEl) timeEl.textContent = `${elapsed}s`; // Update phase based on elapsed time for (let i = phases.length - 1; i >= 0; i--) { if (elapsed >= phases[i].time && i > currentPhaseIndex) { currentPhaseIndex = i; if (titleEl) titleEl.textContent = phases[i].title; if (hintEl) hintEl.textContent = phases[i].hint; break; } } }, 1000); // Listen for real-time progress updates from detailed generation const handlePlanProgress = (event) => { if (event.type === 'plan:progress' && event.data) { const titleEl = document.getElementById('planLoadingTitle'); const hintEl = document.getElementById('planLoadingHint'); if (titleEl && event.data.phase) { const phaseLabels = { 'research': 'Research agent working...', 'parallel-analysis': 'Spawning analysis subagents...', 'subagent': event.data.detail || 'Subagent working...', 'synthesis': 'Synthesizing results...', 'verification': 'Running verification...', 'review-injection': 'Adding review tasks...', 'execution-optimization': 'Optimizing for Claude Code...', 'final-review': 'Running final review...', }; titleEl.textContent = phaseLabels[event.data.phase] || event.data.phase; } if (hintEl && event.data.detail) { hintEl.textContent = event.data.detail; } } }; // Add SSE listener for detailed mode progress if (isDetailed) { this._planProgressHandler = handlePlanProgress; } try { // Use different endpoint for detailed mode const endpoint = isDetailed ? '/api/generate-plan-detailed' : '/api/generate-plan'; const body = isDetailed ? { taskDescription: config.taskDescription, caseName: config.caseName } : { taskDescription: config.taskDescription, detailLevel: config.planDetailLevel }; // Retry logic for network errors const maxRetries = 3; let lastError = null; let data = null; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: this.planGenerationAbortController?.signal, }); data = await res.json(); break; // Success, exit retry loop } catch (fetchErr) { lastError = fetchErr; // Don't retry if aborted if (fetchErr.name === 'AbortError') throw fetchErr; // Network error - retry with exponential backoff console.warn(`[Plan] Fetch attempt ${attempt + 1} failed:`, fetchErr.message); if (attempt < maxRetries - 1) { const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s const titleEl = document.getElementById('planLoadingTitle'); const hintEl = document.getElementById('planLoadingHint'); if (titleEl) titleEl.textContent = 'Connection lost, retrying...'; if (hintEl) hintEl.textContent = `Attempt ${attempt + 2} of ${maxRetries} in ${delay / 1000}s`; await new Promise(r => setTimeout(r, delay)); } } } if (!data) { throw lastError || new Error('Failed to fetch after retries'); } // Stop timer if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); this.planLoadingTimer = null; } // Remove progress handler this._planProgressHandler = null; if (!data.success) { this.showPlanError(data.error || 'Failed to generate plan'); return; } if (!data.data?.items || data.data.items.length === 0) { this.showPlanError('No plan items generated. Try adding more detail to your task.'); return; } // Show "Done!" with quality info for detailed mode const doneTitle = document.getElementById('planLoadingTitle'); const doneHint = document.getElementById('planLoadingHint'); const spinnerEl = document.querySelector('.plan-spinner'); if (doneTitle) doneTitle.textContent = 'Done!'; if (doneHint) { if (isDetailed && data.data.metadata?.qualityScore) { const quality = Math.round(data.data.metadata.qualityScore * 100); doneHint.textContent = `Generated ${data.data.items.length} steps (Quality: ${quality}%)`; } else { doneHint.textContent = `Generated ${data.data.items.length} steps`; } } if (spinnerEl) spinnerEl.style.display = 'none'; // Brief pause to show "Done!" before showing editor await new Promise(r => setTimeout(r, 500)); // Store plan with enabled state and IDs config.generatedPlan = data.data.items.map((item, idx) => ({ ...item, enabled: true, id: `plan-${Date.now()}-${idx}`, })); config.planGenerated = true; config.skipPlanGeneration = false; config.planCost = data.data.costUsd || 0; // Store metadata for detailed mode if (isDetailed && data.data.metadata) { config.planMetadata = data.data.metadata; } // Show editor and update detail buttons this.renderPlanEditor(); this.updateDetailLevelButtons(); // Check for auto-start if (this.ralphWizardConfig.autoStart) { console.log('[RalphWizard] Auto-start enabled, starting loop automatically...'); this.showToast('Plan complete! Auto-starting Ralph Loop...', 'success'); // Small delay to let user see the plan briefly await new Promise(r => setTimeout(r, 1500)); this.startRalphLoop(); } } catch (err) { // Stop timer if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); this.planLoadingTimer = null; } // Remove progress handler this._planProgressHandler = null; // Ignore abort errors (user cancelled, e.g., clicked "Use Existing Plan") if (err.name === 'AbortError') { console.log('Plan generation aborted by user'); return; } console.error('Plan generation failed:', err); this.showPlanError('Network error: ' + err.message); } } setPlanDetail(level) { const previousLevel = this.ralphWizardConfig.planDetailLevel; this.ralphWizardConfig.planDetailLevel = level; this.updateDetailLevelButtons(); // If plan was already generated and level changed, automatically regenerate if (this.ralphWizardConfig.planGenerated && previousLevel !== level) { const modeLabel = level === 'detailed' ? 'Enhanced (Multi-Agent)' : level === 'brief' ? 'Brief' : 'Standard'; console.log(`[Ralph Wizard] Plan mode changed to ${modeLabel}, regenerating...`); // Clear current plan and regenerate this.ralphWizardConfig.generatedPlan = null; this.ralphWizardConfig.planGenerated = false; this.ralphWizardConfig.planMetadata = null; // Trigger regeneration with visual feedback this.generatePlan(); } } updateDetailLevelButtons() { const level = this.ralphWizardConfig.planDetailLevel; document.querySelectorAll('.plan-detail-btn').forEach(btn => { const isActive = btn.dataset.detail === level; btn.classList.toggle('active', isActive); // Add visual indicator for enhanced mode if (btn.dataset.detail === 'detailed') { btn.title = 'Enhanced: Uses 4 parallel subagents + verification (slower but more thorough)'; } else if (btn.dataset.detail === 'standard') { btn.title = 'Standard: Single-pass generation with Opus 4.5'; } else { btn.title = 'Brief: High-level milestones only'; } }); } showPlanError(message) { document.getElementById('planGenerationLoading')?.classList.add('hidden'); document.getElementById('planEditor')?.classList.add('hidden'); const errorEl = document.getElementById('planGenerationError'); const msgEl = document.getElementById('planErrorMsg'); const stoppedIndicator = document.getElementById('planStoppedIndicator'); // Hide the stopped indicator for real errors, show error message if (stoppedIndicator) stoppedIndicator.style.display = 'none'; if (msgEl) msgEl.textContent = message; errorEl?.classList.remove('hidden'); } // Plan view mode: 'list' (flat) or 'grouped' (by TDD phase) planViewMode = 'list'; renderPlanEditor() { document.getElementById('planGenerationLoading')?.classList.add('hidden'); document.getElementById('planGenerationError')?.classList.add('hidden'); document.getElementById('planEditor')?.classList.remove('hidden'); // Close plan subagent windows since generation is complete this.closePlanSubagentWindows(); const list = document.getElementById('planItemsList'); if (!list) return; list.innerHTML = ''; const items = this.ralphWizardConfig.generatedPlan || []; const cost = this.ralphWizardConfig.planCost || 0; const metadata = this.ralphWizardConfig.planMetadata; // Update header with item count, cost, and quality score if available const statsEl = document.getElementById('planStats'); if (statsEl) { let statsText = `${items.length} steps · $${cost.toFixed(3)}`; // Show quality score and source info for detailed mode if (metadata) { const quality = Math.round(metadata.qualityScore * 100); const qualityClass = quality >= 80 ? 'quality-high' : quality >= 60 ? 'quality-medium' : 'quality-low'; statsText = `${quality}% quality · ${items.length} steps · $${cost.toFixed(3)}`; // Add subagent breakdown if available if (metadata.synthesisStats?.sourceBreakdown) { const sources = metadata.synthesisStats.sourceBreakdown; const sourceList = Object.entries(sources) .map(([src, count]) => `${src}: ${count}`) .join(', '); statsText += ` · Sources: ${sourceList}`; } } statsEl.innerHTML = statsText; } // Show warnings/gaps if present from verification const warningsEl = document.getElementById('planWarnings'); if (warningsEl) { if (metadata?.verificationWarnings?.length > 0 || metadata?.verificationGaps?.length > 0) { let warningsHtml = ''; if (metadata.verificationGaps?.length > 0) { warningsHtml += `
Gaps identified: ${metadata.verificationGaps.join('; ')}
`; } if (metadata.verificationWarnings?.length > 0) { warningsHtml += `
Warnings: ${metadata.verificationWarnings.join('; ')}
`; } warningsEl.innerHTML = warningsHtml; warningsEl.classList.remove('hidden'); } else { warningsEl.classList.add('hidden'); } } // Render based on view mode - use DocumentFragment for batch DOM updates if (this.planViewMode === 'grouped' && items.some(i => i.tddPhase)) { this.renderPlanItemsGrouped(list, items); } else { // Flat list view with DocumentFragment for performance const fragment = document.createDocumentFragment(); items.forEach((item, index) => { const row = this.renderPlanItem(item, index); fragment.appendChild(row); }); list.appendChild(fragment); } } // Render plan items grouped by TDD phase - uses DocumentFragment for batch DOM updates renderPlanItemsGrouped(container, items) { const phases = ['setup', 'test', 'impl', 'review', 'verify', null]; const phaseLabels = { setup: { label: 'Setup', icon: '🔧' }, test: { label: 'Tests', icon: '🧪' }, impl: { label: 'Implementation', icon: '💻' }, review: { label: 'Code Review', icon: '🔍' }, verify: { label: 'Verification', icon: '✅' }, null: { label: 'Other', icon: '📋' }, }; // First, create indexed items to preserve original positions (non-mutating) const indexedItems = items.map((item, idx) => ({ ...item, _originalIndex: idx })); // Build all DOM elements in a DocumentFragment first (no reflows) const fragment = document.createDocumentFragment(); phases.forEach(phase => { // Filter using indexed items const phaseItems = indexedItems.filter(item => (item.tddPhase || null) === phase); if (phaseItems.length === 0) return; const phaseKey = phase || 'null'; const { label, icon } = phaseLabels[phaseKey]; // Create group header const header = document.createElement('div'); header.className = 'plan-group-header'; header.dataset.phase = phaseKey; header.innerHTML = ` ${icon} ${label} ${phaseItems.length} ▼ `; header.onclick = () => this.togglePlanGroup(phaseKey); fragment.appendChild(header); // Create group items container const groupItems = document.createElement('div'); groupItems.className = 'plan-group-items'; groupItems.dataset.phase = phaseKey; phaseItems.forEach(item => { const row = this.renderPlanItem(item, item._originalIndex); groupItems.appendChild(row); }); fragment.appendChild(groupItems); }); // Single DOM operation - append all at once container.appendChild(fragment); } togglePlanGroup(phase) { const header = document.querySelector(`.plan-group-header[data-phase="${phase}"]`); const items = document.querySelector(`.plan-group-items[data-phase="${phase}"]`); if (header && items) { header.classList.toggle('collapsed'); items.classList.toggle('collapsed'); } } setPlanViewMode(mode) { this.planViewMode = mode; // Update view toggle buttons document.querySelectorAll('.plan-view-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === mode); }); this.renderPlanEditor(); } renderPlanItem(item, index) { const row = document.createElement('div'); // Build class with priority indicator let className = 'plan-item'; if (item.priority) { className += ` priority-${item.priority.toLowerCase()}`; } row.className = className; row.dataset.index = index; row.draggable = true; // Add TDD phase badge with enhanced styling const phaseIcon = this.getTddPhaseIcon(item.tddPhase); const phaseClass = item.tddPhase ? `phase-${item.tddPhase}` : ''; const phaseTitle = item.tddPhase ? `TDD Phase: ${item.tddPhase}` : ''; // Build verification tooltip const verificationTitle = item.verificationCriteria ? `Verification: ${item.verificationCriteria}` : ''; // Dependencies indicator const depsCount = item.dependencies?.length || 0; const depsTitle = depsCount > 0 ? `Depends on: ${item.dependencies.join(', ')}` : ''; // Review checklist indicator for review phase tasks const checklistCount = item.reviewChecklist?.length || 0; const checklistTitle = checklistCount > 0 ? `Review Checklist:\n• ${item.reviewChecklist.join('\n• ')}` : ''; row.innerHTML = ` ☰ ${phaseIcon ? `${phaseIcon}` : ''} ${depsCount > 0 ? `↗ ${depsCount}` : ''} ${item.verificationCriteria ? `✓ verify` : ''} ${checklistCount > 0 ? `☑ ${checklistCount}` : ''}
${checklistCount > 0 ? `` : ''}
`; // Add drag-and-drop event handlers row.addEventListener('dragstart', (e) => this.handlePlanItemDragStart(e, index)); row.addEventListener('dragend', (e) => this.handlePlanItemDragEnd(e)); row.addEventListener('dragover', (e) => this.handlePlanItemDragOver(e)); row.addEventListener('drop', (e) => this.handlePlanItemDrop(e, index)); return row; } // Drag and drop handlers for plan items handlePlanItemDragStart(e, index) { this._draggedPlanIndex = index; e.target.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', index); } handlePlanItemDragEnd(e) { e.target.classList.remove('dragging'); // Remove drag-over class from all items document.querySelectorAll('.plan-item.drag-over').forEach(el => { el.classList.remove('drag-over'); }); this._draggedPlanIndex = null; } handlePlanItemDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const item = e.target.closest('.plan-item'); if (item && !item.classList.contains('dragging')) { // Remove drag-over from other items document.querySelectorAll('.plan-item.drag-over').forEach(el => { if (el !== item) el.classList.remove('drag-over'); }); item.classList.add('drag-over'); } } handlePlanItemDrop(e, targetIndex) { e.preventDefault(); const sourceIndex = this._draggedPlanIndex; if (sourceIndex !== null && sourceIndex !== targetIndex) { const plan = this.ralphWizardConfig.generatedPlan; if (plan) { // Move the item const [movedItem] = plan.splice(sourceIndex, 1); plan.splice(targetIndex, 0, movedItem); this.renderPlanEditor(); } } } // Edit verification criteria inline editPlanItemVerification(index) { const plan = this.ralphWizardConfig.generatedPlan; if (!plan || !plan[index]) return; const currentCriteria = plan[index].verificationCriteria || ''; const newCriteria = prompt('Verification criteria:', currentCriteria); if (newCriteria !== null) { plan[index].verificationCriteria = newCriteria.trim() || undefined; this.renderPlanEditor(); } } // Duplicate a plan item duplicatePlanItem(index) { const plan = this.ralphWizardConfig.generatedPlan; if (!plan || !plan[index]) return; const original = plan[index]; const duplicate = { ...original, id: `plan-${Date.now()}-dup`, content: original.content + ' (copy)', }; plan.splice(index + 1, 0, duplicate); this.renderPlanEditor(); } showReviewChecklist(index) { const plan = this.ralphWizardConfig.generatedPlan; if (!plan || !plan[index]) return; const item = plan[index]; if (!item.reviewChecklist || item.reviewChecklist.length === 0) return; // Create modal content const checklistHtml = item.reviewChecklist.map((check, i) => `
  • `).join(''); const content = `

    Review Checklist

    ${this.escapeHtml(item.content)}

      ${checklistHtml}
    `; // Show in a modal const modal = document.createElement('div'); modal.className = 'modal checklist-modal-container'; modal.innerHTML = ``; modal.onclick = (e) => { if (e.target === modal) modal.remove(); }; document.body.appendChild(modal); } getTddPhaseIcon(phase) { switch (phase) { case 'setup': return '🔧'; case 'test': return '🧪'; case 'impl': return '💻'; case 'verify': return '✅'; case 'review': return '🔍'; default: return ''; } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } togglePlanItem(index) { const plan = this.ralphWizardConfig.generatedPlan; if (plan && plan[index]) { plan[index].enabled = !plan[index].enabled; this.renderPlanEditor(); } } updatePlanItemContent(index, value) { const plan = this.ralphWizardConfig.generatedPlan; if (plan && plan[index]) { plan[index].content = value.trim(); } } updatePlanItemPriority(index, value) { const plan = this.ralphWizardConfig.generatedPlan; if (plan && plan[index]) { plan[index].priority = value || null; } } removePlanItem(index) { const plan = this.ralphWizardConfig.generatedPlan; if (plan) { plan.splice(index, 1); this.renderPlanEditor(); } } addPlanItem() { const plan = this.ralphWizardConfig.generatedPlan || []; plan.push({ content: '', priority: null, enabled: true, id: `plan-${Date.now()}-new`, }); this.ralphWizardConfig.generatedPlan = plan; this.renderPlanEditor(); // Focus the new item's input setTimeout(() => { const list = document.getElementById('planItemsList'); const lastInput = list?.querySelector('.plan-item:last-child .plan-item-content'); lastInput?.focus(); }, 50); } async cancelPlanGeneration() { this.stopPlanGeneration(); this.planGenerationStopped = true; // Ignore future SSE events // Call the cancel API to stop server-side processing if (this.activePlanOrchestratorId) { try { console.log('[Cancel] Sending cancel request for', this.activePlanOrchestratorId); await fetch('/api/cancel-plan-generation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orchestratorId: this.activePlanOrchestratorId }), }); this.activePlanOrchestratorId = null; } catch (err) { console.error('[Cancel] Failed to cancel plan generation:', err); } } this.showToast('Plan generation stopped', 'info'); // Allow user to proceed without a plan by clicking Next this.ralphWizardConfig.skipPlanGeneration = true; // Show stopped state with clear visual indicator const errorEl = document.getElementById('planGenerationError'); const msgEl = document.getElementById('planErrorMsg'); const stoppedIndicator = document.getElementById('planStoppedIndicator'); // Show the stopped indicator, hide error message since this was intentional if (stoppedIndicator) stoppedIndicator.style.display = 'flex'; if (msgEl) msgEl.textContent = ''; // Hide spinner immediately and show stopped state document.getElementById('planGenerationLoading')?.classList.add('hidden'); errorEl?.classList.remove('hidden'); // Close all plan subagent windows this.closePlanSubagentWindows(); } // ========== Plan Subagent Windows (Visible Opus Agents) ========== handlePlanSubagentEvent(event) { // Ignore events if user has stopped plan generation if (this.planGenerationStopped) return; const { type, agentId, agentType, model, status, detail, itemCount, durationMs, error } = event; if (type === 'started') { // Create new plan subagent window this.createPlanSubagentWindow(agentId, agentType, model, detail); } else if (type === 'completed' || type === 'failed') { // Update window to show completion/failure this.updatePlanSubagentWindow(agentId, status, itemCount, durationMs, error); } else if (type === 'progress') { // Update progress detail const windowData = this.planSubagents.get(agentId); if (windowData?.element) { const detailEl = windowData.element.querySelector('.plan-subagent-detail'); if (detailEl) detailEl.textContent = detail || ''; } } // Update monitor panel to show plan agents this.renderMonitorPlanAgents(); } /** * Find a free position for a window that doesn't overlap with existing windows. * Tries positions around the wizard, avoiding all occupied spaces. */ findFreeWindowPosition(windowWidth, windowHeight, wizardRect, preferredSide = 'right') { const gap = 30; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const minY = 60; const maxY = viewportHeight - windowHeight - 10; // Collect all occupied rectangles (plan subagent windows + regular subagent windows) const occupiedRects = []; // Add wizard rect as occupied if (wizardRect) { occupiedRects.push({ left: wizardRect.left - gap, top: wizardRect.top, right: wizardRect.right + gap, bottom: wizardRect.bottom, }); } // Add existing plan subagent windows for (const [, windowData] of this.planSubagents) { if (windowData.element) { const rect = windowData.element.getBoundingClientRect(); occupiedRects.push({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, }); } } // Add regular subagent windows for (const [, windowInfo] of this.subagentWindows) { if (windowInfo.element && !windowInfo.hidden && !windowInfo.minimized) { const rect = windowInfo.element.getBoundingClientRect(); occupiedRects.push({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, }); } } // Check if a position overlaps with any occupied rectangle const overlaps = (x, y) => { const newRect = { left: x, top: y, right: x + windowWidth, bottom: y + windowHeight, }; return occupiedRects.some(rect => newRect.left < rect.right && newRect.right > rect.left && newRect.top < rect.bottom && newRect.bottom > rect.top ); }; // Try positions on preferred side first, then other side const sides = preferredSide === 'right' ? ['right', 'left'] : ['left', 'right']; for (const side of sides) { // Calculate base X position for this side let baseX; if (wizardRect) { if (side === 'right') { baseX = wizardRect.right + gap; } else { baseX = wizardRect.left - windowWidth - gap; } } else { baseX = side === 'right' ? viewportWidth - windowWidth - 50 : 50; } // Clamp to viewport baseX = Math.max(10, Math.min(baseX, viewportWidth - windowWidth - 10)); // Try vertical positions from top to bottom for (let y = minY; y <= maxY; y += windowHeight + 15) { if (!overlaps(baseX, y)) { return { x: baseX, y }; } } // If side is full, try additional columns const columnOffset = side === 'right' ? windowWidth + gap : -(windowWidth + gap); for (let col = 1; col <= 2; col++) { const x = baseX + col * columnOffset; if (x < 10 || x > viewportWidth - windowWidth - 10) continue; for (let y = minY; y <= maxY; y += windowHeight + 15) { if (!overlaps(x, y)) { return { x, y }; } } } } // Fallback: cascade position const fallbackOffset = this.planSubagents.size * 30; return { x: Math.min(50 + fallbackOffset, viewportWidth - windowWidth - 10), y: Math.min(120 + fallbackOffset, maxY), }; } createPlanSubagentWindow(agentId, agentType, model, detail) { if (this.planSubagents.has(agentId)) return; const wizardModal = document.getElementById('ralphWizardModal'); const wizardContent = wizardModal?.querySelector('.modal-content'); const wizardRect = wizardContent?.getBoundingClientRect(); // Calculate position using smart positioning that avoids overlaps const windowWidth = 280; const windowHeight = 100; const windowCount = this.planSubagents.size; // Alternate preferred side: even=right, odd=left const preferredSide = windowCount % 2 === 0 ? 'right' : 'left'; const { x, y } = this.findFreeWindowPosition(windowWidth, windowHeight, wizardRect, preferredSide); // Create window element const win = document.createElement('div'); win.className = 'plan-subagent-window'; win.id = `plan-subagent-${agentId}`; win.style.left = `${x}px`; win.style.top = `${y}px`; win.style.zIndex = ++this.planSubagentWindowZIndex; const typeLabels = { research: 'Research Agent', requirements: 'Requirements Analyst', architecture: 'Architecture Planner', testing: 'TDD Specialist', risks: 'Risk Analyst', verification: 'Verification Expert', execution: 'Execution Optimizer', 'final-review': 'Final Review', }; const typeIcons = { research: '🔬', requirements: '📋', architecture: '🏗️', testing: '🧪', risks: '⚠️', verification: '✓', execution: '⚡', 'final-review': '🔍', }; win.innerHTML = `
    ${typeIcons[agentType] || '🤖'} ${typeLabels[agentType] || agentType} ${model}
    Running...
    ${detail || ''}
    `; // Add click handler for prompt link const promptLink = win.querySelector('.plan-subagent-prompt-link'); promptLink.addEventListener('click', (e) => { e.stopPropagation(); this.showPlanAgentPrompt(agentId, agentType); }); document.body.appendChild(win); // Calculate relative position to wizard let relativePos = null; if (wizardRect) { relativePos = { dx: x - wizardRect.left, dy: y - wizardRect.top, }; } // Make the window draggable const header = win.querySelector('.plan-subagent-header'); const dragListeners = this.makePlanSubagentDraggable(win, header); // Store reference with relative position and drag listeners this.planSubagents.set(agentId, { agentId, type: agentType, model, status: 'running', startTime: Date.now(), element: win, relativePos, dragListeners, }); // If agents are minimized, hide this new window too if (this.planAgentsMinimized) { win.style.display = 'none'; } // Update wizard agent count button this.updateWizardAgentCount(); // Update connection lines this.updateConnectionLines(); } makePlanSubagentDraggable(win, handle) { let isDragging = false; let startX, startY, startLeft, startTop; let dragUpdateScheduled = false; const mousedownHandler = (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; startLeft = parseInt(win.style.left) || 0; startTop = parseInt(win.style.top) || 0; win.style.zIndex = ++this.planSubagentWindowZIndex; e.preventDefault(); }; const moveHandler = (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; // Constrain to viewport const winWidth = win.offsetWidth || 280; const winHeight = win.offsetHeight || 100; const maxX = window.innerWidth - winWidth - 10; const maxY = window.innerHeight - winHeight - 10; const newLeft = Math.max(10, Math.min(startLeft + dx, maxX)); const newTop = Math.max(10, Math.min(startTop + dy, maxY)); win.style.left = `${newLeft}px`; win.style.top = `${newTop}px`; // Throttle connection line updates if (!dragUpdateScheduled) { dragUpdateScheduled = true; requestAnimationFrame(() => { this.updateConnectionLines(); dragUpdateScheduled = false; }); } }; const upHandler = () => { isDragging = false; }; handle.addEventListener('mousedown', mousedownHandler); document.addEventListener('mousemove', moveHandler); document.addEventListener('mouseup', upHandler); return { mousedown: mousedownHandler, move: moveHandler, up: upHandler }; } updatePlanSubagentWindow(agentId, status, itemCount, durationMs, error) { const windowData = this.planSubagents.get(agentId); if (!windowData?.element) return; const win = windowData.element; const statusEl = win.querySelector('.plan-subagent-status'); const statusTextEl = win.querySelector('.plan-subagent-status-text'); const spinnerEl = win.querySelector('.plan-subagent-spinner'); const detailEl = win.querySelector('.plan-subagent-detail'); windowData.status = status; windowData.itemCount = itemCount; if (status === 'completed') { statusEl?.classList.remove('running'); statusEl?.classList.add('completed'); if (spinnerEl) spinnerEl.style.display = 'none'; if (statusTextEl) { statusTextEl.textContent = `Done (${itemCount || 0} items)`; statusTextEl.classList.add('clickable'); statusTextEl.title = 'Click to view result files'; statusTextEl.onclick = (e) => { e.stopPropagation(); this.showPlanAgentFiles(windowData.agentId, windowData.type); }; } if (detailEl && durationMs) detailEl.textContent = `${(durationMs / 1000).toFixed(1)}s`; } else if (status === 'failed' || status === 'cancelled') { statusEl?.classList.remove('running'); statusEl?.classList.add('failed'); if (spinnerEl) spinnerEl.style.display = 'none'; if (statusTextEl) statusTextEl.textContent = status === 'cancelled' ? 'Cancelled' : 'Failed'; if (detailEl) detailEl.textContent = error || ''; } // Update minimized tab if agents are hidden if (this.planAgentsMinimized) { this.createAgentsMinimizedTab(); } // Update connection lines this.updateConnectionLines(); } closePlanSubagentWindows() { for (const [agentId, windowData] of this.planSubagents) { // Clean up drag listeners if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); } if (windowData.element) { windowData.element.remove(); } } this.planSubagents.clear(); this.updateWizardAgentCount(); this.removeAgentsMinimizedTab(); this.updateConnectionLines(); // Update monitor panel (will hide section if no agents) this.renderMonitorPlanAgents(); } // ========== Wizard Dragging ========== setupWizardDragging() { const modal = document.getElementById('ralphWizardModal'); const wizardContent = modal?.querySelector('.modal-content'); const header = wizardContent?.querySelector('.modal-header'); if (!wizardContent || !header) return; // Clean up any existing listeners this.cleanupWizardDragging(); let isDragging = false; let startX, startY, startLeft, startTop; let dragUpdateScheduled = false; let agentStartPositions = new Map(); // Track agent window start positions during drag const mousedownHandler = (e) => { // Don't drag if clicking buttons if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return; isDragging = true; // If wizard hasn't been moved yet, calculate its current centered position if (!this.wizardPosition) { const rect = wizardContent.getBoundingClientRect(); this.wizardPosition = { left: rect.left, top: rect.top }; } startX = e.clientX; startY = e.clientY; startLeft = this.wizardPosition.left; startTop = this.wizardPosition.top; // Capture agent window start positions agentStartPositions.clear(); for (const [agentId, windowData] of this.planSubagents) { if (windowData.element) { agentStartPositions.set(agentId, { left: parseInt(windowData.element.style.left) || 0, top: parseInt(windowData.element.style.top) || 0, }); } } // Switch to absolute positioning wizardContent.classList.add('draggable'); wizardContent.style.left = `${startLeft}px`; wizardContent.style.top = `${startTop}px`; e.preventDefault(); }; const moveHandler = (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; // Constrain to viewport const winWidth = wizardContent.offsetWidth || 480; const winHeight = wizardContent.offsetHeight || 400; const maxX = window.innerWidth - winWidth - 10; const maxY = window.innerHeight - winHeight - 10; const newLeft = Math.max(10, Math.min(startLeft + dx, maxX)); const newTop = Math.max(10, Math.min(startTop + dy, maxY)); this.wizardPosition = { left: newLeft, top: newTop }; wizardContent.style.left = `${newLeft}px`; wizardContent.style.top = `${newTop}px`; // Move agent windows using their start positions + delta this.moveAgentWindowsWithWizard(agentStartPositions, dx, dy); // Throttle connection line updates if (!dragUpdateScheduled) { dragUpdateScheduled = true; requestAnimationFrame(() => { this.updateConnectionLines(); dragUpdateScheduled = false; }); } }; const upHandler = () => { if (isDragging) { isDragging = false; // Save agent window positions for next drag this.saveAgentWindowRelativePositions(); } }; header.addEventListener('mousedown', mousedownHandler); document.addEventListener('mousemove', moveHandler); document.addEventListener('mouseup', upHandler); this.wizardDragListeners = { header, mousedown: mousedownHandler, move: moveHandler, up: upHandler, }; } cleanupWizardDragging() { if (this.wizardDragListeners) { const { header, mousedown, move, up } = this.wizardDragListeners; header?.removeEventListener('mousedown', mousedown); document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); this.wizardDragListeners = null; } } moveAgentWindowsWithWizard(agentStartPositions, dx, dy) { // Move plan subagent windows using their start positions + delta for (const [agentId, windowData] of this.planSubagents) { if (windowData.element && !this.planAgentsMinimized) { const startPos = agentStartPositions.get(agentId); if (startPos) { windowData.element.style.left = `${startPos.left + dx}px`; windowData.element.style.top = `${startPos.top + dy}px`; } } } } saveAgentWindowRelativePositions() { const wizardModal = document.getElementById('ralphWizardModal'); const wizardContent = wizardModal?.querySelector('.modal-content'); if (!wizardContent) return; const wizardRect = wizardContent.getBoundingClientRect(); for (const [, windowData] of this.planSubagents) { if (windowData.element) { const winRect = windowData.element.getBoundingClientRect(); windowData.relativePos = { dx: winRect.left - wizardRect.left, dy: winRect.top - wizardRect.top, }; } } } // ========== Agent Windows Minimize/Expand ========== togglePlanAgentsMinimized() { this.planAgentsMinimized = !this.planAgentsMinimized; if (this.planAgentsMinimized) { // Hide all agent windows and show tab for (const [, windowData] of this.planSubagents) { if (windowData.element) { windowData.element.style.display = 'none'; } } this.createAgentsMinimizedTab(); } else { // Show all agent windows and remove tab for (const [, windowData] of this.planSubagents) { if (windowData.element) { windowData.element.style.display = ''; } } this.removeAgentsMinimizedTab(); } this.updateConnectionLines(); } createAgentsMinimizedTab() { // Remove existing tab if any this.removeAgentsMinimizedTab(); const runningCount = Array.from(this.planSubagents.values()) .filter(w => w.status === 'running').length; const totalCount = this.planSubagents.size; const tab = document.createElement('div'); tab.className = 'wizard-agents-tab'; tab.id = 'wizardAgentsMinimizedTab'; tab.onclick = () => this.togglePlanAgentsMinimized(); tab.innerHTML = ` 🤖 Plan Agents ${totalCount} ${runningCount > 0 ? ` ${runningCount} running ` : ''} `; document.body.appendChild(tab); } removeAgentsMinimizedTab() { document.getElementById('wizardAgentsMinimizedTab')?.remove(); } updateWizardAgentCount() { const btn = document.getElementById('wizardMinimizeAgentsBtn'); const countEl = document.getElementById('wizardAgentCount'); if (!btn || !countEl) return; const count = this.planSubagents.size; countEl.textContent = count; btn.style.display = count > 0 ? '' : 'none'; // Update minimized tab if it exists if (this.planAgentsMinimized) { this.createAgentsMinimizedTab(); } } // ========== Plan Agent Prompts & Files Viewer ========== async showPlanAgentPrompt(agentId, agentType) { const caseName = this.ralphWizardConfig?.caseName; if (!caseName) { console.error('No case name available'); return; } try { const filePath = `${agentType}/prompt.md`; const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/ralph-wizard/file/${encodeURIComponent(filePath)}`); const data = await res.json(); if (!data.success) { this.showPlanFileWindow(agentType, 'prompt.md', `Error: ${data.error}`, false); return; } this.showPlanFileWindow(agentType, 'prompt.md', data.data.content, false); } catch (err) { console.error('Failed to fetch prompt:', err); this.showPlanFileWindow(agentType, 'prompt.md', `Error fetching prompt: ${err.message}`, false); } } async showPlanAgentFiles(agentId, agentType) { const caseName = this.ralphWizardConfig?.caseName; if (!caseName) { console.error('No case name available'); return; } // Create file manager popup this.showPlanFileManager(agentType, caseName); } showPlanFileManager(agentType, caseName) { // Remove existing file manager if any document.getElementById('planFileManager')?.remove(); const typeLabels = { research: 'Research Agent', requirements: 'Requirements Analyst', architecture: 'Architecture Planner', testing: 'TDD Specialist', risks: 'Risk Analyst', verification: 'Verification Expert', execution: 'Execution Optimizer', 'final-review': 'Final Review', }; const fm = document.createElement('div'); fm.className = 'plan-file-manager'; fm.id = 'planFileManager'; fm.innerHTML = `
    ${typeLabels[agentType] || agentType} Files
    Loading files...
    `; document.body.appendChild(fm); // Make draggable this.makePlanFileManagerDraggable(fm); // Fetch files this.loadPlanFileManagerFiles(agentType, caseName, fm); } async loadPlanFileManagerFiles(agentType, caseName, fm) { const body = fm.querySelector('.plan-fm-body'); try { // First try to get the result.json const resultPath = `${agentType}/result.json`; const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/ralph-wizard/file/${encodeURIComponent(resultPath)}`); const data = await res.json(); if (!data.success) { body.innerHTML = `
    No result file found
    `; return; } const parsed = data.data.parsed; let items = []; // Extract items based on agent type if (agentType === 'research' && parsed?.findings) { // Research has nested structure items = [ { name: 'External Resources', type: 'section', count: parsed.findings.externalResources?.length || 0 }, { name: 'Codebase Patterns', type: 'section', count: parsed.findings.codebasePatterns?.length || 0 }, { name: 'Technical Recommendations', type: 'section', count: parsed.findings.technicalRecommendations?.length || 0 }, { name: 'Potential Challenges', type: 'section', count: parsed.findings.potentialChallenges?.length || 0 }, { name: 'Recommended Tools', type: 'section', count: parsed.findings.recommendedTools?.length || 0 }, ]; } else if (parsed?.items) { items = parsed.items.map((item, idx) => ({ name: item.category || item.content?.substring(0, 50) || `Item ${idx + 1}`, type: 'item', content: item.content || JSON.stringify(item, null, 2), rationale: item.rationale, })); } else { // Fallback: show raw JSON structure items = Object.keys(parsed || {}).map(key => ({ name: key, type: 'key', content: JSON.stringify(parsed[key], null, 2), })); } body.innerHTML = `
    📄 prompt.md
    📊 result.json
    Result Contents (${items.length} items)
    ${items.map((item, idx) => `
    ${item.type === 'section' ? '📁' : '📝'} ${this.escapeHtml(item.name)} ${item.count !== undefined ? `${item.count}` : ''}
    `).join('')}
    `; // Store items data for click handlers fm._itemsData = items; fm._parsedData = parsed; fm._caseName = caseName; fm._agentType = agentType; // Add click handlers body.querySelectorAll('.plan-fm-file').forEach(el => { el.onclick = () => this.openPlanFile(agentType, caseName, el.dataset.file); }); body.querySelectorAll('.plan-fm-item').forEach(el => { el.onclick = () => this.openPlanFileItem(fm, parseInt(el.dataset.itemIdx)); }); } catch (err) { body.innerHTML = `
    Error: ${err.message}
    `; } } async openPlanFile(agentType, caseName, fileName) { const filePath = `${agentType}/${fileName}`; try { const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/ralph-wizard/file/${encodeURIComponent(filePath)}`); const data = await res.json(); if (!data.success) { this.showPlanFileWindow(agentType, fileName, `Error: ${data.error}`, fileName.endsWith('.json')); return; } const content = fileName.endsWith('.json') ? JSON.stringify(data.data.parsed, null, 2) : data.data.content; this.showPlanFileWindow(agentType, fileName, content, fileName.endsWith('.json')); } catch (err) { this.showPlanFileWindow(agentType, fileName, `Error: ${err.message}`, false); } } openPlanFileItem(fm, itemIdx) { const items = fm._itemsData; const parsed = fm._parsedData; const agentType = fm._agentType; if (!items || itemIdx >= items.length) return; const item = items[itemIdx]; // For research agent, expand the section if (agentType === 'research' && item.type === 'section') { let sectionData; switch (item.name) { case 'External Resources': sectionData = parsed.findings?.externalResources; break; case 'Codebase Patterns': sectionData = parsed.findings?.codebasePatterns; break; case 'Technical Recommendations': sectionData = parsed.findings?.technicalRecommendations; break; case 'Potential Challenges': sectionData = parsed.findings?.potentialChallenges; break; case 'Recommended Tools': sectionData = parsed.findings?.recommendedTools; break; } const content = JSON.stringify(sectionData, null, 2); this.showPlanFileWindow(agentType, item.name, content, true); } else { // Show item content const content = item.content || JSON.stringify(item, null, 2); this.showPlanFileWindow(agentType, item.name, content, typeof item.content !== 'string'); } } showPlanFileWindow(agentType, fileName, content, isJson) { const windowId = `plan-file-${Date.now()}`; const win = document.createElement('div'); win.className = 'plan-file-window'; win.id = windowId; win.style.left = `${100 + Math.random() * 200}px`; win.style.top = `${100 + Math.random() * 100}px`; win.style.zIndex = ++this.planSubagentWindowZIndex; const typeLabels = { research: 'Research', requirements: 'Requirements', architecture: 'Architecture', testing: 'Testing', risks: 'Risks', verification: 'Verification', execution: 'Execution', 'final-review': 'Review', }; win.innerHTML = `
    ${typeLabels[agentType] || agentType} / ${this.escapeHtml(fileName)}
    ${this.escapeHtml(content)}
    `; document.body.appendChild(win); // Make draggable and resizable this.makePlanFileWindowDraggable(win); this.makePlanFileWindowResizable(win); } makePlanFileManagerDraggable(fm) { const header = fm.querySelector('.plan-fm-header'); let isDragging = false; let startX, startY, startLeft, startTop; header.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON') return; isDragging = true; startX = e.clientX; startY = e.clientY; startLeft = parseInt(fm.style.left) || (window.innerWidth / 2 - 150); startTop = parseInt(fm.style.top) || (window.innerHeight / 2 - 200); fm.style.left = `${startLeft}px`; fm.style.top = `${startTop}px`; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; fm.style.left = `${Math.max(10, startLeft + dx)}px`; fm.style.top = `${Math.max(10, startTop + dy)}px`; }); document.addEventListener('mouseup', () => { isDragging = false; }); } makePlanFileWindowDraggable(win) { const header = win.querySelector('.plan-file-window-header'); let isDragging = false; let startX, startY, startLeft, startTop; header.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON') return; isDragging = true; startX = e.clientX; startY = e.clientY; startLeft = parseInt(win.style.left) || 0; startTop = parseInt(win.style.top) || 0; win.style.zIndex = ++this.planSubagentWindowZIndex; e.preventDefault(); }); const moveHandler = (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; win.style.left = `${Math.max(10, startLeft + dx)}px`; win.style.top = `${Math.max(10, startTop + dy)}px`; }; const upHandler = () => { isDragging = false; }; document.addEventListener('mousemove', moveHandler); document.addEventListener('mouseup', upHandler); } makePlanFileWindowResizable(win) { const resizer = win.querySelector('.plan-file-window-resize'); let isResizing = false; let startX, startY, startWidth, startHeight; resizer.addEventListener('mousedown', (e) => { isResizing = true; startX = e.clientX; startY = e.clientY; startWidth = win.offsetWidth; startHeight = win.offsetHeight; e.preventDefault(); e.stopPropagation(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const dx = e.clientX - startX; const dy = e.clientY - startY; win.style.width = `${Math.max(300, startWidth + dx)}px`; win.style.height = `${Math.max(200, startHeight + dy)}px`; }); document.addEventListener('mouseup', () => { isResizing = false; }); } skipPlanGeneration() { // Stop any running timers if (this.planLoadingTimer) { clearInterval(this.planLoadingTimer); this.planLoadingTimer = null; } if (this.planPhaseTimer) { clearInterval(this.planPhaseTimer); this.planPhaseTimer = null; } this.ralphWizardConfig.skipPlanGeneration = true; this.ralphWizardConfig.planGenerated = false; this.ralphWizardConfig.generatedPlan = null; // Generate preview and go to step 3 this.updateRalphPromptPreview(); this.ralphWizardStep = 3; this.updateRalphWizardUI(); } regeneratePlan() { this.ralphWizardConfig.generatedPlan = null; this.ralphWizardConfig.planGenerated = false; this.generatePlan(); } generateFixPlanContent(items) { // Group items by priority const p0Items = items.filter(i => i.priority === 'P0'); const p1Items = items.filter(i => i.priority === 'P1'); const p2Items = items.filter(i => i.priority === 'P2'); const noPriorityItems = items.filter(i => !i.priority); let content = '# Implementation Plan\n\n'; content += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`; if (p0Items.length > 0) { content += '## Critical Path (P0)\n\n'; p0Items.forEach(item => { content += `- [ ] ${item.content}\n`; }); content += '\n'; } if (p1Items.length > 0) { content += '## Standard (P1)\n\n'; p1Items.forEach(item => { content += `- [ ] ${item.content}\n`; }); content += '\n'; } if (p2Items.length > 0) { content += '## Nice-to-Have (P2)\n\n'; p2Items.forEach(item => { content += `- [ ] ${item.content}\n`; }); content += '\n'; } if (noPriorityItems.length > 0) { content += '## Tasks\n\n'; noPriorityItems.forEach(item => { content += `- [ ] ${item.content}\n`; }); content += '\n'; } return content; } async startRalphLoop() { const config = this.ralphWizardConfig; // Read advanced options (respawn disabled by default for Ralph Loops) config.enableRespawn = document.getElementById('ralphEnableRespawn')?.checked ?? false; // Check either auto-start checkbox (step 2 or step 3) config.autoStart = document.getElementById('ralphAutoStart')?.checked || document.getElementById('ralphAutoStartStep2')?.checked || this.ralphWizardConfig.autoStart || false; // Close wizard this.closeRalphWizard(); // Determine if we have a task plan const hasPlan = config.generatedPlan && config.generatedPlan.filter(i => i.enabled).length > 0; // Build the full prompt following Ralph Wiggum methodology let fullPrompt = config.taskDescription; fullPrompt += '\n\n---\n\n'; // Add @fix_plan.md reference if plan was generated if (hasPlan) { fullPrompt += '## Task Plan\n\n'; fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n'; fullPrompt += '- Reference the plan at the start of each iteration\n'; fullPrompt += '- Update task checkboxes as you complete items\n'; fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n'; } fullPrompt += '## Iteration Protocol\n\n'; fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n'; fullPrompt += '1. Check what work has already been done\n'; fullPrompt += '2. Make incremental progress toward completion\n'; fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n'; fullPrompt += '## Verification\n\n'; fullPrompt += 'After each significant change:\n'; fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n'; fullPrompt += '- Check for type/lint errors if applicable\n'; fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n'; fullPrompt += '## Completion Criteria\n\n'; fullPrompt += `Output \`${config.completionPhrase}\` when ALL of the following are true:\n`; fullPrompt += '- All requirements from the task description are implemented\n'; fullPrompt += '- All tests pass\n'; fullPrompt += '- Changes are committed\n\n'; fullPrompt += '## If Stuck\n\n'; fullPrompt += 'If you encounter the same error for 3+ iterations:\n'; fullPrompt += '1. Document what you\'ve tried\n'; fullPrompt += '2. Identify the specific blocker\n'; fullPrompt += '3. Try an alternative approach\n'; fullPrompt += '4. If truly blocked, output `BLOCKED` with an explanation\n'; try { // Step 1: Create/verify case exists and start session this.terminal.clear(); this.terminal.writeln(`\x1b[1;33m Starting Ralph Loop in ${config.caseName}...\x1b[0m`); this.terminal.writeln(''); // Create session via quick-start const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caseName: config.caseName, mode: 'claude' }) }); const data = await res.json(); if (!data.success) { this.showToast(data.error || 'Failed to start session', 'error'); return; } const sessionId = data.sessionId; // Step 2: Configure Ralph tracker for this session await fetch(`/api/sessions/${sessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, completionPhrase: config.completionPhrase, maxIterations: config.maxIterations || null, }) }); // Step 2.5: Write @fix_plan.md if plan was generated if (config.generatedPlan && config.generatedPlan.length > 0) { const enabledItems = config.generatedPlan.filter(i => i.enabled); if (enabledItems.length > 0) { const planContent = this.generateFixPlanContent(enabledItems); // Import the plan content and get the todos back const importRes = await fetch(`/api/sessions/${sessionId}/fix-plan/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: planContent }) }); const importData = await importRes.json(); // Update local ralph state immediately to avoid 100% display bug // (SSE event might arrive after selectSession is called) if (importData.success && importData.data?.todos) { this.updateRalphState(sessionId, { todos: importData.data.todos }); } // Write to disk await fetch(`/api/sessions/${sessionId}/fix-plan/write`, { method: 'POST' }); } } // Step 3: Enable respawn if requested (with Ralph-specific prompts) // NOTE: Prompts must be single-line because screen-manager.ts strips newlines if (config.enableRespawn) { const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.'; const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, check CLAUDE.md for notes from previous iterations, continue on the next uncompleted task, output ${config.completionPhrase} when ALL tasks are complete.`; await fetch(`/api/sessions/${sessionId}/respawn/enable`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updatePrompt: ralphUpdatePrompt, sendClear: true, sendInit: true, kickstartPrompt: ralphKickstartPrompt, }) }); } // Step 4: Switch to session and wait for it to be ready await this.selectSession(sessionId); // Wait for Claude CLI to be ready (shows prompt) // Check session status multiple times - Claude CLI can take a while to initialize let attempts = 0; const maxAttempts = 30; // 15 seconds total let sessionReady = false; console.log('[RalphWizard] Waiting for session to become ready...'); while (attempts < maxAttempts) { await new Promise(r => setTimeout(r, 500)); try { const statusRes = await fetch(`/api/sessions/${sessionId}`); const statusData = await statusRes.json(); // Session is ready ONLY when Claude CLI shows its UI: // Must see prompt character '❯' OR 'tokens' status line // Do NOT use isWorking=false - that fires before CLI is ready! const termBuf = statusData?.terminalBuffer || ''; const hasPrompt = termBuf.includes('❯'); const hasTokensLine = termBuf.includes('tokens'); if (hasPrompt || hasTokensLine) { console.log(`[RalphWizard] Session ready after ${attempts + 1} attempts (hasPrompt=${hasPrompt}, hasTokensLine=${hasTokensLine})`); sessionReady = true; break; } } catch (e) { console.warn('[RalphWizard] Error checking session status:', e); } attempts++; } if (!sessionReady) { console.warn(`[RalphWizard] Session did not become ready after ${maxAttempts} attempts, sending prompt anyway`); this.showToast('Session took longer than expected to initialize, sending prompt...', 'warning'); // Add extra delay to give Claude CLI more time await new Promise(r => setTimeout(r, 2000)); } // Add delay after session ready before sending prompt console.log('[RalphWizard] Adding 2s delay before sending prompt...'); await new Promise(r => setTimeout(r, 2000)); // Write prompt to file first (avoids screen input escaping issues) console.log(`[RalphWizard] Writing prompt to @ralph_prompt.md (${fullPrompt.length} chars)...`); const writeRes = await fetch(`/api/sessions/${sessionId}/ralph-prompt/write`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: fullPrompt }) }); if (!writeRes.ok) { const errorData = await writeRes.json().catch(() => ({})); throw new Error(errorData.error || `Failed to write prompt file: ${writeRes.status}`); } console.log('[RalphWizard] Prompt file written.'); // If plan was generated, send /init first to load CLAUDE.md with research context // This gives Claude awareness of all the research files before starting tasks if (hasPlan) { console.log('[RalphWizard] Sending /init to load research context from CLAUDE.md...'); const initRes = await fetch(`/api/sessions/${sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: '/init\r', useScreen: true }) }); if (!initRes.ok) { console.warn('[RalphWizard] Failed to send /init, continuing anyway...'); } else { // Wait for /init to complete - can take up to 3 minutes for large CLAUDE.md files console.log('[RalphWizard] Waiting for /init to complete (can take up to 3 minutes)...'); let initComplete = false; const initMaxAttempts = 180; // 3 minutes at 1 second intervals for (let attempt = 0; attempt < initMaxAttempts; attempt++) { await new Promise(r => setTimeout(r, 1000)); try { const statusRes = await fetch(`/api/sessions/${sessionId}`); const statusData = await statusRes.json(); const termBuf = statusData?.terminalBuffer || ''; // /init is complete when we see the prompt indicator (❯) and not working const hasPrompt = termBuf.includes('❯'); const isIdle = !statusData.isWorking; if (hasPrompt && isIdle) { console.log(`[RalphWizard] /init complete after ${attempt + 1}s, Claude now has research context.`); initComplete = true; break; } // Log progress every 15 seconds if ((attempt + 1) % 15 === 0) { console.log(`[RalphWizard] Still waiting for /init... ${attempt + 1}s elapsed (isWorking=${statusData.isWorking})`); } } catch (e) { console.warn('[RalphWizard] Status check error:', e); } } if (!initComplete) { console.warn('[RalphWizard] /init did not complete within 3 minutes, proceeding anyway...'); this.showToast('/init took longer than expected, prompt may not have full context', 'warning'); } // Delay after /init to let Claude settle console.log('[RalphWizard] Adding 3s delay after /init...'); await new Promise(r => setTimeout(r, 3000)); } } console.log('[RalphWizard] Sending read command to Claude...'); // Send a simple command to Claude to read the prompt file // This avoids all the escaping issues with long multi-line prompts const readCommand = 'Read @ralph_prompt.md and follow the instructions. Start working immediately.\r'; const inputRes = await fetch(`/api/sessions/${sessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: readCommand, useScreen: true }) }); if (!inputRes.ok) { const errorData = await inputRes.json().catch(() => ({})); throw new Error(errorData.error || `Failed to send input: ${inputRes.status}`); } console.log('[RalphWizard] Read command sent, verifying it was received...'); // Verify the prompt was actually received by checking for activity let verified = false; for (let verifyAttempt = 0; verifyAttempt < 15; verifyAttempt++) { await new Promise(r => setTimeout(r, 1000)); try { const verifyRes = await fetch(`/api/sessions/${sessionId}`); const verifyData = await verifyRes.json(); if (verifyData.isWorking || verifyData.tokens?.total > 0) { console.log(`[RalphWizard] Prompt verified! tokens=${verifyData.tokens?.total}`); verified = true; break; } console.log(`[RalphWizard] Verify attempt ${verifyAttempt + 1}: status=${verifyData.status}, tokens=${verifyData.tokens?.total || 0}`); } catch (e) { console.warn('[RalphWizard] Verify error:', e); } } if (!verified) { console.warn('[RalphWizard] Session may not have started yet - prompt file is at @ralph_prompt.md'); this.showToast('Session started - check @ralph_prompt.md if prompt not received', 'warning'); } else { this.showToast(`Ralph Loop started in ${config.caseName}`, 'success'); } } catch (err) { console.error('Failed to start Ralph loop:', err); this.showToast('Failed to start Ralph loop: ' + err.message, 'error'); } } // ========== Quick Start ========== async loadQuickStartCases(selectCaseName = null) { try { // Load settings to get lastUsedCase let lastUsedCase = null; try { const settingsRes = await fetch('/api/settings'); if (settingsRes.ok) { const settings = await settingsRes.json(); lastUsedCase = settings.lastUsedCase || null; } } catch { // Ignore settings load errors } // Add cache-busting to ensure fresh data const res = await fetch('/api/cases?_t=' + Date.now()); 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'); cases.forEach(c => { 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); } else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) { // Use lastUsedCase if available and exists select.value = lastUsedCase; this.updateDirDisplayForCase(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); } else { // No cases exist yet - show the default case name as directory select.value = 'testcase'; document.getElementById('dirDisplay').textContent = '~/claudeman-cases/testcase'; } // Only add event listener once (on first load) if (!select.dataset.listenerAdded) { select.addEventListener('change', () => { this.updateDirDisplayForCase(select.value); this.saveLastUsedCase(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() { // Alias for backward compatibility return this.runClaude(); } // 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}`); } // 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 }) }).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`); } } 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 const dims = this.fitAddon.proposeDimensions(); if (dims) { await Promise.all(sessionIds.map(id => fetch(`/api/sessions/${id}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }) )); } // 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`); } } // ========== 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) { const tokensEl = this.$('respawnTokens'); // Support both old format (number) and new format (object with input/output/total) const isObject = tokens && typeof tokens === 'object'; const total = isObject ? tokens.total : tokens; 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 estimatedCost = this.estimateCost(input, output); tokensEl.textContent = `${tokenStr} tokens · $${estimatedCost.toFixed(2)}`; } else { tokensEl.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 += `
    ${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}] ${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 fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' }); delete this.respawnTimers[this.activeSessionId]; this.clearCountdownTimers(this.activeSessionId); } catch (err) { this.showToast('Failed to stop respawn', 'error'); } } // ========== Kill Sessions ========== async killActiveSession() { if (!this.activeSessionId) { this.showToast('No active session', 'warning'); return; } await this.closeSession(this.activeSessionId); } async killAllSessions() { if (this.sessions.size === 0) return; if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return; try { await fetch('/api/sessions', { method: 'DELETE' }); this.sessions.clear(); this.terminalBuffers.clear(); this.activeSessionId = null; this.respawnStatus = {}; this.respawnCountdownTimers = {}; this.respawnActionLogs = {}; this.stopCountdownInterval(); this.hideRespawnBanner(); this.renderSessionTabs(); this.terminal.clear(); this.showWelcome(); this.showToast('All sessions killed', 'success'); } catch (err) { this.showToast('Failed to kill sessions', 'error'); } } // ========== Terminal Controls ========== clearTerminal() { this.terminal.clear(); } // Send Ctrl+L to fix display for newly created sessions once Claude is running sendPendingCtrlL(sessionId) { console.log('[DEBUG] sendPendingCtrlL called for:', sessionId, 'pending:', this.pendingCtrlL ? [...this.pendingCtrlL] : 'none'); if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) { console.log('[DEBUG] No pending Ctrl+L for this session'); return; } this.pendingCtrlL.delete(sessionId); // Only send if this is the active session if (sessionId !== this.activeSessionId) { console.log('[DEBUG] Not active session, skipping Ctrl+L'); return; } console.log('[DEBUG] Sending resize + Ctrl+L for session:', sessionId); // Send resize + Ctrl+L to fix the display const dims = this.fitAddon.proposeDimensions(); if (dims) { fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }).then(() => { console.log('[DEBUG] Resize sent, now sending Ctrl+L'); 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('claudeman-font-size', size); } loadFontSize() { const saved = localStorage.getItem('claudeman-font-size'); if (saved) { const size = parseInt(saved, 10); if (size >= 10 && size <= 24) { this.terminal.options.fontSize = size; document.getElementById('fontSizeDisplay').textContent = size; } } } // ========== Timer ========== showTimer() { document.getElementById('timerBanner').style.display = 'flex'; this.updateTimer(); this.timerInterval = setInterval(() => this.updateTimer(), 1000); } hideTimer() { document.getElementById('timerBanner').style.display = 'none'; if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; } } updateTimer() { if (!this.currentRun || this.currentRun.status !== 'running') return; const now = Date.now(); const remaining = Math.max(0, this.currentRun.endAt - now); const total = this.currentRun.endAt - this.currentRun.startedAt; const elapsed = now - this.currentRun.startedAt; const percent = Math.min(100, (elapsed / total) * 100); document.getElementById('timerValue').textContent = this.formatTime(remaining); document.getElementById('timerProgress').style.width = `${percent}%`; document.getElementById('timerMeta').textContent = `${this.currentRun.completedTasks} tasks | $${this.currentRun.totalCost.toFixed(2)}`; } async stopCurrentRun() { if (!this.currentRun) return; try { await fetch(`/api/scheduled/${this.currentRun.id}`, { method: 'DELETE' }); } catch (err) { this.showToast('Failed to stop run', 'error'); } } formatTime(ms) { const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } // ========== Tokens ========== updateCost() { // Now updates tokens instead of cost this.updateTokens(); } updateTokens() { // Use global stats if available (includes deleted sessions) let totalInput = 0; let totalOutput = 0; if (this.globalStats) { totalInput = this.globalStats.totalInputTokens || 0; totalOutput = this.globalStats.totalOutputTokens || 0; } else { // Fallback to active sessions only this.sessions.forEach(s => { if (s.tokens) { totalInput += s.tokens.input || 0; totalOutput += s.tokens.output || 0; } }); } const total = totalInput + totalOutput; this.totalTokens = total; const display = this.formatTokens(total); // Estimate cost from tokens (more accurate than stored cost in interactive mode) const estimatedCost = this.estimateCost(totalInput, totalOutput); const tokenEl = this.$('headerTokens'); if (tokenEl) { tokenEl.textContent = total > 0 ? `${display} tokens · $${estimatedCost.toFixed(2)}` : '0 tokens'; tokenEl.title = this.globalStats ? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created\nEstimated cost based on Claude Opus pricing` : 'Token usage across active sessions\nEstimated cost based on Claude Opus pricing'; } } // ========== Session Options Modal ========== openSessionOptions(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; this.editingSessionId = sessionId; // Reset to Respawn tab this.switchOptionsTab('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'; } // 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; // Populate Ralph Wiggum form with current session values 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, }); document.getElementById('sessionOptionsModal').classList.add('active'); } async autoSaveAutoCompact() { if (!this.editingSessionId) return; try { await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 fetch(`/api/sessions/${this.editingSessionId}/image-watcher`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 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 fetch(`/api/sessions/${this.editingSessionId}/respawn/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(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; } } // 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'); } // ========== 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: ${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 stats document.getElementById('rsStat-cycles').textContent = stats.totalRespawnCycles; document.getElementById('rsStat-tokens').textContent = this.formatTokens(stats.peakTokens || stats.totalTokensUsed); document.getElementById('rsStat-active').textContent = this.formatDuration(stats.totalTimeActiveMs); document.getElementById('rsStat-issues').textContent = stats.errorCount + stats.warningCount; // 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}
    ${this.escapeHtml(event.title)} ${time}
    ${event.details ? `
    ${this.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 } } formatTokens(tokens) { if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`; if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`; return String(tokens || 0); } 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(); 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(); } }); } // ========== App Settings Modal ========== openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || ''; document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || ''; document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? false; // Header visibility settings (default to true/enabled) document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? true; document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? true; document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? true; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? true; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? true; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? false; document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? true; document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? false; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? true; // 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'; }; // 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; // 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'); } // 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'); } 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'); } 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, 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, // Claude CLI settings claudeMode: document.getElementById('appSettingsClaudeMode').value, allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), // CPU Priority settings nice: { enabled: document.getElementById('appSettingsNiceEnabled').checked, niceValue: parseInt(document.getElementById('appSettingsNiceValue').value) || 10, }, }; // Save to localStorage localStorage.setItem('claudeman-app-settings', JSON.stringify(settings)); // 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, }; if (this.notificationManager) { this.notificationManager.preferences = notifPrefsToSave; this.notificationManager.savePreferences(); } // Apply header visibility immediately this.applyHeaderVisibilitySettings(); 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) try { await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...settings, notificationPreferences: notifPrefsToSave }) }); // Save model configuration separately await this.saveModelConfigFromSettings(); this.showToast('Settings saved', 'success'); } 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 || 'sonnet'; } // 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 || 'sonnet', 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); } } // Get the global Ralph tracker enabled setting isRalphTrackerEnabledByDefault() { const settings = this.loadAppSettingsFromStorage(); return settings.ralphTrackerEnabled ?? false; } loadAppSettingsFromStorage() { try { const saved = localStorage.getItem('claudeman-app-settings'); if (saved) { return JSON.parse(saved); } } catch (err) { console.error('Failed to load app settings:', err); } return {}; } applyHeaderVisibilitySettings() { const settings = this.loadAppSettingsFromStorage(); // Default all to true (enabled) if not set const showFontControls = settings.showFontControls ?? true; const showSystemStats = settings.showSystemStats ?? true; const showTokenCount = settings.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 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'); } } applyMonitorVisibility() { const settings = this.loadAppSettingsFromStorage(); const showMonitor = settings.showMonitor ?? true; const showSubagents = settings.showSubagents ?? true; const showFileBrowser = settings.showFileBrowser ?? false; const monitorPanel = document.getElementById('monitorPanel'); if (monitorPanel) { monitorPanel.style.display = showMonitor ? '' : 'none'; } 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); } 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; localStorage.setItem('claudeman-app-settings', JSON.stringify(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; localStorage.setItem('claudeman-app-settings', JSON.stringify(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; localStorage.setItem('claudeman-app-settings', JSON.stringify(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() { try { const res = await fetch('/api/settings'); if (res.ok) { const settings = await res.json(); // Extract notification prefs before merging app settings const { notificationPreferences, ...appSettings } = settings; // Merge app settings with localStorage (server takes precedence) const localSettings = this.loadAppSettingsFromStorage(); const merged = { ...localSettings, ...appSettings }; localStorage.setItem('claudeman-app-settings', JSON.stringify(merged)); // Apply notification prefs from server if present (only if localStorage has none) if (notificationPreferences && this.notificationManager) { const localNotifPrefs = localStorage.getItem('claudeman-notification-prefs'); if (!localNotifPrefs) { this.notificationManager.preferences = notificationPreferences; this.notificationManager.savePreferences(); } } return merged; } } catch (err) { console.error('Failed to load settings from server:', err); } return this.loadAppSettingsFromStorage(); } // ========== Subagent Window State Persistence ========== /** * Save subagent window states (minimized/open) to server for cross-browser persistence. * Called when a window is minimized, restored, or auto-minimized on completion. */ async saveSubagentWindowStates() { // Build state object: which agents are minimized per session const minimizedState = {}; for (const [sessionId, agentIds] of this.minimizedSubagents) { minimizedState[sessionId] = Array.from(agentIds); } // Also track which windows are open (not minimized) const openWindows = []; for (const [agentId, windowData] of this.subagentWindows) { if (!windowData.minimized) { openWindows.push({ agentId, position: windowData.position || null }); } } const windowStates = { minimized: minimizedState, open: openWindows }; // Save to localStorage for quick restore localStorage.setItem('claudeman-subagent-window-states', JSON.stringify(windowStates)); // Save to server for cross-browser persistence try { await fetch('/api/subagent-window-states', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(windowStates) }); } catch (err) { console.error('Failed to save subagent window states to server:', err); } } /** * 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('claudeman-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('claudeman-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: [] }; } /** * Restore subagent window states after loading subagents. * Opens windows that were open before, keeps minimized ones minimized. */ async restoreSubagentWindowStates() { const states = await this.loadSubagentWindowStates(); // First, discover parent sessions for all subagents to establish correct mapping // This is important after server restart when parentSessionId isn't set const parentDiscoveryPromises = []; for (const [agentId, agent] of this.subagents) { if (!agent.parentSessionId) { parentDiscoveryPromises.push(this.findParentSessionForSubagent(agentId)); } } if (parentDiscoveryPromises.length > 0) { await Promise.all(parentDiscoveryPromises); } // Restore minimized state, but verify session mapping is correct for (const [savedSessionId, agentIds] of Object.entries(states.minimized || {})) { if (Array.isArray(agentIds) && agentIds.length > 0) { for (const agentId of agentIds) { const agent = this.subagents.get(agentId); if (!agent) continue; // Agent no longer exists // Use discovered parentSessionId, or validate saved sessionId exists const correctSessionId = agent.parentSessionId || (this.sessions.has(savedSessionId) ? savedSessionId : null); if (correctSessionId) { if (!this.minimizedSubagents.has(correctSessionId)) { this.minimizedSubagents.set(correctSessionId, new Set()); } this.minimizedSubagents.get(correctSessionId).add(agentId); } } } } // Restore open windows (for non-completed agents only) for (const { agentId, position } of (states.open || [])) { const agent = this.subagents.get(agentId); // Only restore window if agent exists and is still active/idle (not completed) if (agent && agent.status !== 'completed') { this.openSubagentWindow(agentId); // Restore position if saved (with viewport bounds check) if (position) { const windowData = this.subagentWindows.get(agentId); if (windowData && windowData.element) { // Parse position values and clamp to viewport let left = parseInt(position.left, 10) || 50; let top = parseInt(position.top, 10) || 120; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const windowWidth = 420; const windowHeight = 350; left = Math.max(10, Math.min(left, viewportWidth - windowWidth - 10)); top = Math.max(10, Math.min(top, viewportHeight - windowHeight - 10)); windowData.element.style.left = `${left}px`; windowData.element.style.top = `${top}px`; windowData.position = { left: `${left}px`, top: `${top}px` }; } } } } this.renderSessionTabs(); // Update tab badges this.saveSubagentWindowStates(); // Persist corrected mappings } // ========== Help Modal ========== showHelp() { document.getElementById('helpModal').classList.add('active'); } closeHelp() { document.getElementById('helpModal').classList.remove('active'); } 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 Screen 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.loadScreens(); await fetch('/api/screens/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/screens/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 onMouseDown = (e) => { // Only drag from header, not from buttons if (e.target.closest('button')) 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._dragHandler); header._dragHandler = onMouseDown; header.addEventListener('mousedown', onMouseDown); } // ========== 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 onMouseDown = (e) => { // Only drag from header, not from buttons if (e.target.closest('button')) 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._dragHandler); header._dragHandler = onMouseDown; header.addEventListener('mousedown', onMouseDown); } 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}
    ${this.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; // Disable tracker via API await fetch(`/api/sessions/${this.activeSessionId}/ralph-config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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 fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, { method: 'POST', }); 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; 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(); } async resetCircuitBreaker() { if (!this.activeSessionId) return; try { const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, { method: 'POST', }); if (response.ok) { console.log('Circuit breaker reset'); } } catch (err) { console.error('Failed to reset circuit breaker:', err); } } 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 ${statusBlock.status} ${statusBlock.exitSignal ? '🚪 EXIT' : ''}
    ${workIcon} ${statusBlock.workType} 📁 ${statusBlock.filesModified} files ✓ ${statusBlock.tasksCompletedThisLoop} tasks ${testsIcon} Tests: ${statusBlock.testsStatus}
    `; if (statusBlock.recommendation) { html += `
    ${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() { 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 new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(); }); 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 ? `${agent.modelShort}` : ''; const displayName = agent.description || agent.agentId.substring(0, 7); html.push(`
    🤖 ${this.escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)} ${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'} ${this.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} 💬 ${this.escapeHtml(preview)}
    `; } return ''; }).join(''); const detailTitle = agent.description || `Agent ${agent.agentId}`; const modelBadge = agent.modelShort ? `${agent.modelShort}` : ''; const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens) ? `Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑` : ''; detail.innerHTML = `
    ${this.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 fetch(`/api/subagents/${agentId}`, { method: 'DELETE' }); 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 ${agentId} Transcript

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

    ${this.escapeHtml(content)}
    `); } catch (err) { console.error('Failed to load transcript:', err); alert('Failed to load transcript: ' + err.message); } } // ========== Subagent Parent Session Tracking ========== async findParentSessionForSubagent(agentId) { const agent = this.subagents.get(agentId); if (!agent) return; // Strategy 1: Check if another subagent with the same Claude sessionId already has a parent // This ensures subagents from the same Claude session go to the same Claudeman session if (agent.sessionId) { for (const [otherAgentId, otherAgent] of this.subagents) { if (otherAgentId !== agentId && otherAgent.sessionId === agent.sessionId && otherAgent.parentSessionId && this.sessions.has(otherAgent.parentSessionId)) { // Found a sibling subagent with an assigned parent agent.parentSessionId = otherAgent.parentSessionId; agent.parentSessionName = otherAgent.parentSessionName; this.subagents.set(agentId, agent); this.updateSubagentWindowParent(agentId); this.updateSubagentWindowVisibility(); return; } } } // Strategy 2: Find all sessions that match by workingDir const matchingSessions = []; for (const [sessionId, session] of this.sessions) { try { const resp = await fetch(`/api/sessions/${sessionId}/subagents`); if (!resp.ok) continue; const result = await resp.json(); const subagents = result.data || result.subagents || []; if (subagents.some(s => s.agentId === agentId)) { matchingSessions.push({ sessionId, session }); } } catch (err) { // Ignore errors } } if (matchingSessions.length === 0) { // No matching session found return; } // If only one session matches, use it let chosen = matchingSessions[0]; if (matchingSessions.length > 1) { // Multiple sessions with same workingDir - use heuristics to pick the right one // Prefer the most recently created session (newest session likely spawned the subagent) matchingSessions.sort((a, b) => { const aTime = new Date(a.session.createdAt || 0).getTime(); const bTime = new Date(b.session.createdAt || 0).getTime(); return bTime - aTime; // Newest first }); chosen = matchingSessions[0]; } // Assign the parent agent.parentSessionId = chosen.sessionId; agent.parentSessionName = this.getSessionName(chosen.session); this.subagents.set(agentId, agent); this.updateSubagentWindowParent(agentId); this.updateSubagentWindowVisibility(); } /** * Update parentSessionName for all subagents belonging to a session. * Called when a session is updated (e.g., renamed) to keep cached names fresh. */ updateSubagentParentNames(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; const newName = this.getSessionName(session); let updated = false; for (const [agentId, agent] of this.subagents) { if (agent.parentSessionId === sessionId && agent.parentSessionName !== newName) { agent.parentSessionName = newName; this.subagents.set(agentId, agent); updated = true; // 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; } } } } // Update connection lines if any names changed (visual refresh) if (updated) { this.updateConnectionLines(); } } updateSubagentWindowParent(agentId) { const windowData = this.subagentWindows.get(agentId); if (!windowData) return; const agent = this.subagents.get(agentId); if (!agent?.parentSessionId) return; // Check if parent header already exists const win = windowData.element; if (win.querySelector('.subagent-window-parent')) return; // Insert 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 = agent.parentSessionId; parentDiv.innerHTML = ` from ${this.escapeHtml(agent.parentSessionName)} `; header.insertAdjacentElement('afterend', parentDiv); } // Update connection lines this.updateConnectionLines(); } // ========== Subagent Connection Lines ========== updateConnectionLines() { const svg = document.getElementById('connectionLines'); if (!svg) return; svg.innerHTML = ''; // Check if Ralph wizard modal is open const wizardModal = document.getElementById('ralphWizardModal'); const wizardOpen = wizardModal?.classList.contains('active'); const wizardContent = wizardOpen ? wizardModal.querySelector('.modal-content') : null; // Collect visible regular subagent windows for connection logic const visibleSubagentWindows = []; for (const [agentId, windowInfo] of this.subagentWindows) { if (windowInfo.minimized || windowInfo.hidden) continue; const win = windowInfo.element; if (!win) continue; visibleSubagentWindows.push({ agentId, windowInfo, win }); } // Get plan subagent windows as array for distribution const planSubagentArray = Array.from(this.planSubagents.entries()) .filter(([, data]) => data.element) .map(([id, data]) => ({ id, ...data })); for (const { agentId, windowInfo, win } of visibleSubagentWindows) { const agent = this.subagents.get(agentId); const winRect = win.getBoundingClientRect(); // If wizard is open with plan subagents, connect regular subagents to plan subagent windows if (wizardOpen && wizardContent && planSubagentArray.length > 0) { // Find the nearest plan subagent window to connect to let nearestPlanAgent = null; let nearestDistance = Infinity; for (const planAgent of planSubagentArray) { const planRect = planAgent.element.getBoundingClientRect(); const planCenterX = planRect.left + planRect.width / 2; const planCenterY = planRect.top + planRect.height / 2; const winCenterX = winRect.left + winRect.width / 2; const winCenterY = winRect.top + winRect.height / 2; const distance = Math.hypot(planCenterX - winCenterX, planCenterY - winCenterY); if (distance < nearestDistance) { nearestDistance = distance; nearestPlanAgent = planAgent; } } if (nearestPlanAgent) { const planRect = nearestPlanAgent.element.getBoundingClientRect(); // Draw line from plan subagent window to regular subagent window let x1, y1, x2, y2; const planCenterX = planRect.left + planRect.width / 2; const winCenterX = winRect.left + winRect.width / 2; if (winCenterX < planCenterX) { // Regular window is to the left of plan window x1 = planRect.left; y1 = planRect.top + planRect.height / 2; x2 = winRect.right; y2 = winRect.top + winRect.height / 2; } else { // Regular window is to the right of plan window x1 = planRect.right; y1 = planRect.top + planRect.height / 2; x2 = winRect.left; y2 = winRect.top + winRect.height / 2; } // Bezier curve for smooth connection const midX = (x1 + x2) / 2; const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); line.setAttribute('d', path); line.setAttribute('class', 'connection-line plan-to-subagent-line'); line.setAttribute('data-agent-id', agentId); line.setAttribute('data-plan-agent-id', nearestPlanAgent.id); svg.appendChild(line); } } else if (wizardOpen && wizardContent) { // Wizard open but no plan subagents - connect directly to wizard const wizardRect = wizardContent.getBoundingClientRect(); const winCenterX = winRect.left + winRect.width / 2; const wizardCenterX = wizardRect.left + wizardRect.width / 2; let x1, y1, x2, y2; if (winCenterX < wizardCenterX) { x1 = wizardRect.left; y1 = wizardRect.top + wizardRect.height / 2; x2 = winRect.right; y2 = winRect.top + winRect.height / 2; } else { x1 = wizardRect.right; y1 = wizardRect.top + wizardRect.height / 2; x2 = winRect.left; y2 = winRect.top + winRect.height / 2; } const midX = (x1 + x2) / 2; const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); line.setAttribute('d', path); line.setAttribute('class', 'connection-line wizard-connection'); line.setAttribute('data-agent-id', agentId); svg.appendChild(line); } else if (agent?.parentSessionId) { // Normal mode - connect to parent session tab const tab = document.querySelector(`.session-tab[data-id="${agent.parentSessionId}"]`); if (!tab) continue; const tabRect = tab.getBoundingClientRect(); // Draw curved line from tab bottom-center to window top-center const x1 = tabRect.left + tabRect.width / 2; const y1 = tabRect.bottom; const x2 = winRect.left + winRect.width / 2; const y2 = winRect.top; // Bezier curve control points for smooth curve const midY = (y1 + y2) / 2; const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`; const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); line.setAttribute('d', path); line.setAttribute('class', 'connection-line'); line.setAttribute('data-agent-id', agentId); svg.appendChild(line); } } // Draw lines from wizard to plan subagent windows (Opus agents during plan generation) // Skip if agents are minimized to tab if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) { const wizardRect = wizardContent.getBoundingClientRect(); for (const [agentId, windowData] of this.planSubagents) { const win = windowData.element; if (!win) continue; const winRect = win.getBoundingClientRect(); // Determine which side of wizard the window is on const winCenterX = winRect.left + winRect.width / 2; const wizardCenterX = wizardRect.left + wizardRect.width / 2; let x1, y1, x2, y2; if (winCenterX < wizardCenterX) { // Window is on the left x1 = wizardRect.left; y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50); x2 = winRect.right; y2 = winRect.top + winRect.height / 2; } else { // Window is on the right x1 = wizardRect.right; y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50); x2 = winRect.left; y2 = winRect.top + winRect.height / 2; } const midX = (x1 + x2) / 2; const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); line.setAttribute('d', path); line.setAttribute('class', 'connection-line wizard-connection plan-subagent-line'); line.setAttribute('data-plan-agent-id', agentId); svg.appendChild(line); } } } /** * Show/hide subagent windows based on active session. * Behavior controlled by "Subagents for Active Tab Only" setting. */ updateSubagentWindowVisibility() { const settings = this.loadAppSettingsFromStorage(); const activeTabOnly = settings.subagentActiveTabOnly ?? false; for (const [agentId, windowInfo] of this.subagentWindows) { const agent = this.subagents.get(agentId); // Determine visibility based on setting let shouldShow; if (activeTabOnly) { // Show if: no parent known yet, or parent matches active session const hasKnownParent = agent?.parentSessionId; shouldShow = !hasKnownParent || agent.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'; } windowInfo.hidden = false; } else { // Hide window (but don't close it) windowInfo.element.style.display = 'none'; windowInfo.hidden = true; } } // Update connection lines after visibility changes this.updateConnectionLines(); } // ========== Subagent Floating Windows ========== openSubagentWindow(agentId) { // If window already exists, focus it if (this.subagentWindows.has(agentId)) { const existing = this.subagentWindows.get(agentId); const agent = this.subagents.get(agentId); const settings = this.loadAppSettingsFromStorage(); const activeTabOnly = settings.subagentActiveTabOnly ?? false; // If window is hidden (different tab) and activeTabOnly is enabled, switch to parent tab if (existing.hidden && agent?.parentSessionId && activeTabOnly) { this.selectSession(agent.parentSessionId); return; } // If not activeTabOnly mode, just show the window if (existing.hidden && !activeTabOnly) { existing.element.style.display = 'flex'; existing.hidden = false; } existing.element.style.zIndex = ++this.subagentWindowZIndex; if (existing.minimized) { this.restoreSubagentWindow(agentId); } return; } const agent = this.subagents.get(agentId); if (!agent) return; // Calculate final position - grid layout to avoid overlaps const windowCount = this.subagentWindows.size; const windowWidth = 420; const windowHeight = 350; const gap = 20; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Check if Ralph wizard modal is open - if so, position windows on the sides const wizardModal = document.getElementById('ralphWizardModal'); const wizardOpen = wizardModal?.classList.contains('active'); let startX, startY, maxCols; if (wizardOpen) { // Wizard is ~720px wide, centered. Position windows on left/right sides const wizardWidth = 720; const centerX = viewportWidth / 2; const wizardLeft = centerX - wizardWidth / 2; const wizardRight = centerX + wizardWidth / 2; // Alternate between left and right sides of the wizard const leftSideSpace = wizardLeft - 20; const rightSideSpace = viewportWidth - wizardRight - 20; if (windowCount % 2 === 0 && rightSideSpace >= windowWidth) { // Even windows go to the right startX = wizardRight + 20; maxCols = Math.floor(rightSideSpace / (windowWidth + gap)) || 1; } else if (leftSideSpace >= windowWidth) { // Odd windows go to the left startX = Math.max(10, wizardLeft - windowWidth - 20); maxCols = 1; // Usually only room for 1 column on left } else { // Not enough side space, use right side startX = wizardRight + 20; maxCols = 1; } startY = 80; // Start higher when wizard is open } else { // Normal positioning startX = 50; startY = 120; maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1; } const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1; const col = windowCount % maxCols; const row = Math.floor(windowCount / maxCols) % maxRows; // Wrap rows to stay in viewport let finalX = startX + col * (windowWidth + gap); let finalY = startY + row * (windowHeight + gap); // Ensure window stays within viewport bounds finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); // Get parent tab position for spawn animation const parentTab = agent.parentSessionId ? document.querySelector(`.session-tab[data-id="${agent.parentSessionId}"]`) : null; // Create window element const win = document.createElement('div'); win.className = 'subagent-window'; win.id = `subagent-window-${agentId}`; win.style.zIndex = ++this.subagentWindowZIndex; // Build parent header if we have parent info const parentHeader = agent.parentSessionId && agent.parentSessionName ? `
    from ${this.escapeHtml(agent.parentSessionName)}
    ` : ''; const windowTitle = agent.description || agentId.substring(0, 7); const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle; const modelBadge = agent.modelShort ? `${agent.modelShort}` : ''; win.innerHTML = `
    🤖 ${this.escapeHtml(truncatedTitle)} ${modelBadge} ${agent.status}
    ${parentHeader}
    Loading activity...
    `; // If we have a parent tab, start window at tab position for spawn animation if (parentTab) { const tabRect = parentTab.getBoundingClientRect(); win.style.left = `${tabRect.left}px`; win.style.top = `${tabRect.bottom}px`; win.style.transform = 'scale(0.3)'; win.style.opacity = '0'; win.classList.add('spawning'); } else { // No parent tab, just position normally win.style.left = `${finalX}px`; win.style.top = `${finalY}px`; } document.body.appendChild(win); // Make draggable (returns listener refs for cleanup) const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); // Check if this window should be visible based on settings const settings = this.loadAppSettingsFromStorage(); const activeTabOnly = settings.subagentActiveTabOnly ?? false; let shouldHide = false; if (activeTabOnly) { const hasKnownParent = agent.parentSessionId; const isForActiveSession = !hasKnownParent || agent.parentSessionId === this.activeSessionId; shouldHide = !isForActiveSession; } // Store reference (including drag listeners for cleanup) this.subagentWindows.set(agentId, { element: win, minimized: false, hidden: shouldHide, dragListeners, // Store for cleanup to prevent memory leaks }); // Hide window if not for active session if (shouldHide) { win.style.display = 'none'; } // Render content this.renderSubagentWindowContent(agentId); // Focus on click win.addEventListener('mousedown', () => { win.style.zIndex = ++this.subagentWindowZIndex; }); // Update connection lines when window is resized const resizeObserver = new ResizeObserver(() => { this.updateConnectionLines(); }); resizeObserver.observe(win); // Store observer for cleanup this.subagentWindows.get(agentId).resizeObserver = resizeObserver; // Animate to final position if spawning from tab if (parentTab) { requestAnimationFrame(() => { win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; win.style.left = `${finalX}px`; win.style.top = `${finalY}px`; win.style.transform = 'scale(1)'; win.style.opacity = '1'; // Clean up after animation setTimeout(() => { win.style.transition = ''; win.classList.remove('spawning'); this.updateConnectionLines(); }, 400); }); } else { // No animation, just update connection lines this.updateConnectionLines(); } // Persist the state change (new window opened) this.saveSubagentWindowStates(); } async closeSubagentWindow(agentId) { const windowData = this.subagentWindows.get(agentId); if (!windowData) return; const agent = this.subagents.get(agentId); // Try to discover parent session if not already known // This ensures we minimize to the correct tab if (agent && !agent.parentSessionId) { await this.findParentSessionForSubagent(agentId); } // Now get the correct parent session (re-read agent after discovery) const updatedAgent = this.subagents.get(agentId); const parentSessionId = updatedAgent?.parentSessionId || this.activeSessionId; // Always minimize to tab (use active session if no parent found) windowData.element.style.display = 'none'; windowData.minimized = true; // Track minimized agent for the session if (parentSessionId) { if (!this.minimizedSubagents.has(parentSessionId)) { this.minimizedSubagents.set(parentSessionId, new Set()); } this.minimizedSubagents.get(parentSessionId).add(agentId); // Update tab badge to show minimized agents this.renderSessionTabs(); } // Persist the state change this.saveSubagentWindowStates(); this.updateConnectionLines(); } // 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); if (agent?.parentSessionId === 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); } } // 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 global drag event listeners (prevents memory leak) if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); } windowData.element.remove(); this.subagentWindows.delete(agentId); } } // Clean up ALL floating windows (called during handleInit to prevent memory leaks on reconnect) cleanupAllFloatingWindows() { // Clean up all subagent windows with their ResizeObservers and drag listeners for (const [agentId, windowData] of this.subagentWindows) { if (windowData.resizeObserver) { windowData.resizeObserver.disconnect(); } if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); } windowData.element.remove(); } this.subagentWindows.clear(); // Clean up all log viewer windows with their EventSources and drag listeners for (const [windowId, data] of this.logViewerWindows) { if (data.eventSource) { data.eventSource.close(); } if (data.dragListeners) { document.removeEventListener('mousemove', data.dragListeners.move); document.removeEventListener('mouseup', data.dragListeners.up); } data.element.remove(); } this.logViewerWindows.clear(); // Clean up plan subagent windows (wizard agents) if (this.planSubagents) { for (const [agentId, windowData] of this.planSubagents) { if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); } if (windowData.element) { windowData.element.remove(); } } this.planSubagents.clear(); } // Clear orphaned plan generation state this.activePlanOrchestratorId = null; this._planProgressHandler = null; this.planGenerationStopped = true; if (this.planGenerationAbortController) { this.planGenerationAbortController.abort(); this.planGenerationAbortController = null; } // Clear minimized agents tracking this.minimizedSubagents.clear(); // Update monitor panel this.renderMonitorPlanAgents(); // Update connection lines (should be empty now) this.updateConnectionLines(); } minimizeSubagentWindow(agentId) { const windowData = this.subagentWindows.get(agentId); if (windowData) { windowData.element.style.display = 'none'; windowData.minimized = true; this.updateConnectionLines(); } } restoreSubagentWindow(agentId) { const windowData = this.subagentWindows.get(agentId); const agent = this.subagents.get(agentId); // If window doesn't exist but agent does, recreate it if (!windowData && agent) { this.createSubagentWindow(agentId); return; } if (windowData) { const settings = this.loadAppSettingsFromStorage(); const activeTabOnly = settings.subagentActiveTabOnly ?? false; // Determine if we should show the window let shouldShow = true; if (activeTabOnly) { // Only restore if the window belongs to the active session (or has no parent) shouldShow = !agent?.parentSessionId || agent.parentSessionId === this.activeSessionId; } if (shouldShow) { windowData.element.style.display = 'flex'; windowData.element.style.zIndex = ++this.subagentWindowZIndex; windowData.hidden = false; } windowData.minimized = false; this.updateConnectionLines(); } } // Returns drag listener references for cleanup (prevents memory leaks) makeWindowDraggable(win, handle) { let isDragging = false; let startX, startY, startLeft, startTop; let dragUpdateScheduled = false; handle.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON') return; isDragging = true; startX = e.clientX; startY = e.clientY; startLeft = parseInt(win.style.left) || 0; startTop = parseInt(win.style.top) || 0; e.preventDefault(); }); // Store references to document-level listeners so they can be removed on window close const moveListener = (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; // Constrain to viewport bounds const winWidth = win.offsetWidth || 420; const winHeight = win.offsetHeight || 350; const maxX = window.innerWidth - winWidth - 10; const maxY = window.innerHeight - winHeight - 10; const newLeft = Math.max(10, Math.min(startLeft + dx, maxX)); const newTop = Math.max(10, Math.min(startTop + dy, maxY)); win.style.left = `${newLeft}px`; win.style.top = `${newTop}px`; // Throttle connection line updates during drag if (!dragUpdateScheduled) { dragUpdateScheduled = true; requestAnimationFrame(() => { this.updateConnectionLines(); dragUpdateScheduled = false; }); } }; const upListener = () => { if (isDragging) { isDragging = false; // Save position after drag ends this.saveSubagentWindowStates(); } }; document.addEventListener('mousemove', moveListener); document.addEventListener('mouseup', upListener); // Return listener references for cleanup return { move: moveListener, up: upListener }; } renderSubagentWindowContent(agentId) { 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; } const html = activity.slice(-100).map(a => { const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); if (a.type === 'tool') { return `
    ${time} ${this.getToolIcon(a.tool)} ${a.tool} ${this.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 || '→'} ${this.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 displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); return `
    ${time} ${icon} ${this.escapeHtml(displayText)}
    `; } else if (a.type === 'message') { const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; return `
    ${time} 💬 ${this.escapeHtml(preview)}
    `; } return ''; }).join(''); body.innerHTML = html; body.scrollTop = body.scrollHeight; } // 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 windowTitle = agent.description || agentId.substring(0, 7); const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle; idEl.textContent = truncatedTitle; } // 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); } } } // ========== 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 ?? true; 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(`
    💻 ${this.escapeHtml(cmdDisplay)} ${tool.status} ${tool.timeout ? `${this.escapeHtml(tool.timeout)}` : ''}
    `); for (const path of filteredPaths) { const fileName = path.split('/').pop(); html.push(` ${this.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; 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: ${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} ${this.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'); } // Save setting const settings = this.loadAppSettingsFromStorage(); settings.showFileBrowser = false; localStorage.setItem('claudeman-app-settings', JSON.stringify(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 = `${this.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 = `
    ${this.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: ${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 = `
    📄 ${this.escapeHtml(fileName)} streaming
    Connecting to ${this.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 = this.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 += `
    ${this.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 global drag event listeners (prevents memory leak) if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); } // Remove element windowData.element.remove(); // Remove from map this.logViewerWindows.delete(windowId); } // Close all log viewer windows for a session closeSessionLogViewerWindows(sessionId) { for (const [windowId, data] of this.logViewerWindows) { if (data.sessionId === sessionId) { 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, 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; } // 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 const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(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 = `
    🖼️ ${this.escapeHtml(fileName)} ${this.escapeHtml(sessionName)} ${sizeKB} KB
    ${this.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 global drag event listeners if (popupData.dragListeners) { document.removeEventListener('mousemove', popupData.dragListeners.move); document.removeEventListener('mouseup', popupData.dragListeners.up); } // 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) { for (const [imageId, data] of this.imagePopups) { if (data.sessionId === sessionId) { this.closeImagePopup(imageId); } } } // ========== Screen Sessions (in Monitor Panel) ========== async loadScreens() { try { const res = await fetch('/api/screens'); const data = await res.json(); this.screenSessions = data.screens || []; this.renderScreenSessions(); } catch (err) { console.error('Failed to load screens:', err); } } killAllSessions() { const count = this.screenSessions?.length || 0; if (count === 0) { alert('No sessions to kill'); return; } // Show the kill all modal document.getElementById('killAllCount').textContent = count; document.getElementById('killAllModal').classList.add('active'); } closeKillAllModal() { document.getElementById('killAllModal').classList.remove('active'); } async confirmKillAll(killScreens) { this.closeKillAllModal(); try { if (killScreens) { // Kill everything including screens const res = await fetch('/api/sessions', { method: 'DELETE' }); const data = await res.json(); if (data.success) { this.sessions.clear(); this.screenSessions = []; this.activeSessionId = null; this.renderSessionTabs(); this.renderScreenSessions(); this.terminal.clear(); this.terminal.reset(); this.toast('All sessions and screens killed', 'success'); } } else { // Just remove tabs, keep screens running this.sessions.clear(); this.activeSessionId = null; this.renderSessionTabs(); this.terminal.clear(); this.terminal.reset(); this.toast('All tabs removed, screens still running', 'info'); } } catch (err) { console.error('Failed to kill sessions:', err); this.toast('Failed to kill sessions: ' + err.message, 'error'); } } // ========== 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); }); 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() { if (this.caseModalTab === 'case-create') { await this.createCase(); } else { await this.linkCase(); } } 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'); } } renderScreenSessions() { // Debounce renders at 100ms to prevent excessive DOM updates if (this.renderScreenSessionsTimeout) { clearTimeout(this.renderScreenSessionsTimeout); } this.renderScreenSessionsTimeout = setTimeout(() => { this._renderScreenSessionsImmediate(); }, 100); } _renderScreenSessionsImmediate() { const body = document.getElementById('screenSessionsBody'); if (!this.screenSessions || this.screenSessions.length === 0) { body.innerHTML = '
    No screen sessions
    '; return; } let html = ''; for (const screen of this.screenSessions) { const stats = screen.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 }; const modeClass = screen.mode === 'shell' ? 'shell' : ''; html += `
    ${screen.mode}
    ${this.escapeHtml(screen.name || screen.screenName)}
    ${stats.memoryMB}MB ${stats.cpuPercent}% ${stats.childCount} children PID: ${screen.pid}
    `; } 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 ? this.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 killScreen(sessionId) { if (!confirm('Kill this screen session?')) return; try { await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' }); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== sessionId); this.renderScreenSessions(); this.showToast('Screen killed', 'success'); } catch (err) { this.showToast('Failed to kill screen', 'error'); } } async reconcileScreens() { try { const res = await fetch('/api/screens/reconcile', { method: 'POST' }); const data = await res.json(); if (data.dead && data.dead.length > 0) { this.showToast(`Found ${data.dead.length} dead screen(s)`, 'warning'); await this.loadScreens(); } else { this.showToast('All screens are alive', 'success'); } } catch (err) { this.showToast('Failed to reconcile screens', '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 ? this.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} ${this.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') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; // 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); }, 3000); } // ========== 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() { 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'); } } } // ========== Utility ========== // Pre-compiled HTML escape map for performance (avoids DOM element creation) static _htmlEscapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; static _htmlEscapePattern = /[&<>"']/g; escapeHtml(text) { if (!text) return ''; return text.replace(ClaudemanApp._htmlEscapePattern, char => ClaudemanApp._htmlEscapeMap[char]); } } // Initialize const app = new ClaudemanApp();