Files
Codeman/src/web/public/app.js
T
zhouyuan 28dfc279d4 fix: resolve terminal resize scrollback ghost renders
- Switch resize handler to 300ms trailing-edge debounce for single reflow
- Add \x1b[3J (Erase Saved Lines) to clear scrollback reflow debris
- Remove client-side cursor-up flicker filter and DEC 2026 marker
  stripping — xterm.js 6.0 handles synchronized output natively
- Remove server-side DEC 2026 wrapping to prevent premature sync exit
  from non-reference-counted nested markers
2026-03-12 01:04:21 +08:00

12502 lines
477 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Core UI controller for Codeman — tab-based terminal manager with xterm.js.
*
* This is the main application module (~11,500 lines). It defines the CodemanApp class which
* manages the entire frontend: terminal rendering, SSE event handling, session lifecycle,
* settings UI, and all panel systems. Additional methods are mixed in from api-client.js,
* ralph-wizard.js, and subagent-windows.js via Object.assign on CodemanApp.prototype.
*
* ═══ Major Sections ═══
*
* SSE Handler Map (line ~80) — Event-to-method routing table
* CodemanApp Class (line ~189) — Constructor and global state
* Pending Hooks (line ~368) — Hook state machine for tab alerts
* Init (line ~408) — App bootstrap and mobile setup
* Terminal Setup (line ~483) — xterm.js config and input handling
* Terminal Rendering (line ~1053) — batchTerminalWrite, flushPendingWrites, chunkedTerminalWrite
* Event Listeners (line ~1390) — Keyboard shortcuts, resize, beforeunload
* SSE Connection (line ~1474) — connectSSE with exponential backoff (1-30s)
* SSE Event Handlers (line ~1570) — ~60 handler methods (_onSessionCreated, _onRespawnStateChanged, etc.)
* Connection Status (line ~2553) — Online detection, handleInit (full state sync on reconnect)
* Session Tabs (line ~2911) — Tab rendering, selection, drag-and-drop reordering
* Tab Order & Drag-and-Drop (~3143) — Persistent tab ordering with drag reorder
* Session Lifecycle (line ~3268) — Select, close, navigate sessions
* Navigation (line ~3673) — goHome, Ralph wizard stub
* Quick Start (line ~3709) — Case loading, session spawning (Claude, Shell, OpenCode)
* Respawn Banner (line ~4205) — Respawn state display, countdown timers, action log
* Kill Sessions (line ~4640) — Kill active/all sessions
* Terminal Controls (line ~4678) — Clear, resize, copy, font size, sendInput
* Timer / Tokens (line ~4833) — Session timer, token/cost display
* Session Options Modal (line ~4939) — Per-session settings, respawn config, color picker
* Respawn Presets (line ~5188) — Preset CRUD, load/save/delete
* Run Summary Modal (line ~5506) — Timeline events, filtering, export (JSON/Markdown)
* Session Options Tabs (line ~5762) — Ralph config tab within session options
* Web Push (line ~5882) — Service worker registration, push subscribe/unsubscribe
* App Settings Modal (line ~6024) — Global settings, tunnel management, QR auth, voice config
* Session Lifecycle Log (line ~6536) — JSONL audit log viewer
* Visibility Settings (line ~6859) — Header/panel visibility, device-specific defaults
* Persistent Parent Assoc (line ~7218) — Parent session tracking for subagent windows
* Help Modal (line ~7326) — Keyboard shortcuts help
* Token Statistics (line ~7363) — Aggregate token/cost stats across sessions
* Monitor Panel (line ~7490) — Mux sessions + background tasks, detachable panel
* Subagents Panel (line ~7614) — Detachable subagent list panel
* Ralph Panel (line ~7799) — Ralph Loop status, @fix_plan.md integration
* Plan Versioning (line ~8638) — Plan checkpoint/rollback/diff UI
* Subagent Panel (line ~8780) — Agent discovery, window open/close, connection lines
* Subagent Parent Tracking (~9101) — Tab-based agent grouping
* Agent Teams (line ~9595) — Team tasks panel, teammate badges
* Project Insights (line ~9995) — Bash tool tracking with clickable file paths
* File Browser (line ~10310) — Directory tree panel with file preview
* Log Viewer (line ~10619) — Floating file streamer windows (tail -f)
* Image Popups (line ~10768) — Auto-popup windows for detected screenshots
* Mux Sessions (line ~10909) — tmux session list in monitor panel
* Case Settings (line ~10986) — Case CRUD and link management
* Mobile Case Picker (line ~11221) — Touch-friendly case selection modal
* Plan Wizard Agents (line ~11480) — Plan orchestrator subagent display in monitor
* Toast (line ~11566) — Toast notification popups
* System Stats (line ~11618) — CPU/memory polling display
* Module Init (line ~11696) — localStorage migration and app start
*
* After the class: localStorage migration (claudeman-* → codeman-*), app instantiation,
* and window.app / window.MobileDetection exports.
*
* @class CodemanApp
* @globals {CodemanApp} app - Singleton instance (also on window.app)
*
* @dependency constants.js (SSE_EVENTS, timing constants, escapeHtml, extractSyncSegments, DEC sync markers)
* @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler)
* @dependency voice-input.js (VoiceInput, DeepgramProvider)
* @dependency notification-manager.js (NotificationManager class)
* @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap)
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
* @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay)
* @loadorder 6 of 9 — loaded after keyboard-accessory.js, before ralph-wizard.js
*/
// Codeman App - Tab-based Terminal UI
// Constants, utilities, and escapeHtml() are in constants.js (loaded before this file)
// MobileDetection, KeyboardHandler, SwipeHandler are in mobile-handlers.js
// DeepgramProvider, VoiceInput are in voice-input.js
// ═══════════════════════════════════════════════════════════════
// Global Error & Performance Diagnostics
// ═══════════════════════════════════════════════════════════════
// Writes breadcrumbs to localStorage so they survive tab freezes.
// After a crash, check: localStorage.getItem('codeman-crash-diag')
const _crashDiag = {
_entries: [],
_maxEntries: 50,
log(msg) {
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
this._entries.push(entry);
if (this._entries.length > this._maxEntries) this._entries.shift();
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
}
};
// Log previous crash breadcrumbs on startup
try {
const prev = localStorage.getItem('codeman-crash-diag');
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
} catch {}
_crashDiag.log('PAGE LOAD');
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
setInterval(() => {
try {
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
if (_crashDiag._entries.length > 0) {
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
}
} catch {}
}, 2000);
window.addEventListener('error', (e) => {
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack);
});
window.addEventListener('unhandledrejection', (e) => {
_crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`);
console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack);
});
// Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive"
if (typeof PerformanceObserver !== 'undefined') {
try {
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
_crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`);
console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`);
}
}
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
} catch { /* longtask not supported */ }
}
// Track WebGL context loss/restore events on all canvases
const _origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, ...args) {
const ctx = _origGetContext.call(this, type, ...args);
if (type === 'webgl2' || type === 'webgl') {
this.addEventListener('webglcontextlost', (e) => {
_crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`);
console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented);
});
this.addEventListener('webglcontextrestored', () => {
_crashDiag.log('WEBGL_RESTORED');
console.warn('[CRASH-DIAG] WebGL context restored');
});
}
return ctx;
};
// ═══════════════════════════════════════════════════════════════
// SSE Handler Map — event-to-method routing table
// ═══════════════════════════════════════════════════════════════
// connectSSE() iterates this array to register all listeners in a single loop.
// Omitted no-op events (registered by server but unused in UI):
// respawn:stepSent, respawn:aiCheckStarted, respawn:aiCheckCompleted,
// respawn:aiCheckFailed, respawn:aiCheckCooldown
const _SSE_HANDLER_MAP = [
// Core
[SSE_EVENTS.INIT, '_onInit'],
// Session lifecycle
[SSE_EVENTS.SESSION_CREATED, '_onSessionCreated'],
[SSE_EVENTS.SESSION_UPDATED, '_onSessionUpdated'],
[SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted'],
[SSE_EVENTS.SESSION_TERMINAL, '_onSessionTerminal'],
[SSE_EVENTS.SESSION_NEEDS_REFRESH, '_onSessionNeedsRefresh'],
[SSE_EVENTS.SESSION_CLEAR_TERMINAL, '_onSessionClearTerminal'],
[SSE_EVENTS.SESSION_COMPLETION, '_onSessionCompletion'],
[SSE_EVENTS.SESSION_ERROR, '_onSessionError'],
[SSE_EVENTS.SESSION_EXIT, '_onSessionExit'],
[SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'],
[SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'],
[SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'],
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
// Scheduled runs
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
[SSE_EVENTS.SCHEDULED_UPDATED, '_onScheduledUpdated'],
[SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'],
[SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'],
// Respawn
[SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'],
[SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'],
[SSE_EVENTS.RESPAWN_STATE_CHANGED, '_onRespawnStateChanged'],
[SSE_EVENTS.RESPAWN_CYCLE_STARTED, '_onRespawnCycleStarted'],
[SSE_EVENTS.RESPAWN_BLOCKED, '_onRespawnBlocked'],
[SSE_EVENTS.RESPAWN_AUTO_ACCEPT_SENT, '_onRespawnAutoAcceptSent'],
[SSE_EVENTS.RESPAWN_DETECTION_UPDATE, '_onRespawnDetectionUpdate'],
[SSE_EVENTS.RESPAWN_TIMER_STARTED, '_onRespawnTimerStarted'],
[SSE_EVENTS.RESPAWN_TIMER_CANCELLED, '_onRespawnTimerCancelled'],
[SSE_EVENTS.RESPAWN_TIMER_COMPLETED, '_onRespawnTimerCompleted'],
[SSE_EVENTS.RESPAWN_ERROR, '_onRespawnError'],
[SSE_EVENTS.RESPAWN_ACTION_LOG, '_onRespawnActionLog'],
// Tasks
[SSE_EVENTS.TASK_CREATED, '_onTaskCreated'],
[SSE_EVENTS.TASK_COMPLETED, '_onTaskCompleted'],
[SSE_EVENTS.TASK_FAILED, '_onTaskFailed'],
[SSE_EVENTS.TASK_UPDATED, '_onTaskUpdated'],
// Mux (tmux)
[SSE_EVENTS.MUX_CREATED, '_onMuxCreated'],
[SSE_EVENTS.MUX_KILLED, '_onMuxKilled'],
[SSE_EVENTS.MUX_DIED, '_onMuxDied'],
[SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'],
// Ralph
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
[SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'],
[SSE_EVENTS.SESSION_RALPH_COMPLETION_DETECTED, '_onRalphCompletionDetected'],
[SSE_EVENTS.SESSION_RALPH_STATUS_UPDATE, '_onRalphStatusUpdate'],
[SSE_EVENTS.SESSION_CIRCUIT_BREAKER_UPDATE, '_onCircuitBreakerUpdate'],
[SSE_EVENTS.SESSION_EXIT_GATE_MET, '_onExitGateMet'],
// Bash tools
[SSE_EVENTS.SESSION_BASH_TOOL_START, '_onBashToolStart'],
[SSE_EVENTS.SESSION_BASH_TOOL_END, '_onBashToolEnd'],
[SSE_EVENTS.SESSION_BASH_TOOLS_UPDATE, '_onBashToolsUpdate'],
// Hooks (Claude Code hook events)
[SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'],
[SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'],
[SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
// Subagents (Claude Code background agents)
[SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'],
[SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'],
[SSE_EVENTS.SUBAGENT_TOOL_CALL, '_onSubagentToolCall'],
[SSE_EVENTS.SUBAGENT_PROGRESS, '_onSubagentProgress'],
[SSE_EVENTS.SUBAGENT_MESSAGE, '_onSubagentMessage'],
[SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'],
[SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'],
// Images
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
// Tunnel
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
[SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'],
[SSE_EVENTS.TUNNEL_PROGRESS, '_onTunnelProgress'],
[SSE_EVENTS.TUNNEL_ERROR, '_onTunnelError'],
[SSE_EVENTS.TUNNEL_QR_ROTATED, '_onTunnelQrRotated'],
[SSE_EVENTS.TUNNEL_QR_REGENERATED, '_onTunnelQrRegenerated'],
[SSE_EVENTS.TUNNEL_QR_AUTH_USED, '_onTunnelQrAuthUsed'],
// Plan orchestration
[SSE_EVENTS.PLAN_SUBAGENT, '_onPlanSubagent'],
[SSE_EVENTS.PLAN_PROGRESS, '_onPlanProgress'],
[SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'],
[SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'],
[SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'],
];
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
class CodemanApp {
constructor() {
this.sessions = new Map();
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
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._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads
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.muxSessions = []; // Screen sessions for process monitor
// Ralph loop/todo state per session
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
// Subagent (Claude Code background agent) tracking
this.subagents = new Map(); // Map<agentId, SubagentInfo>
this.subagentActivity = new Map(); // Map<agentId, activity[]> - recent tool calls/progress
this.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
this.activeSubagentId = null; // Currently selected subagent for detail view
this.subagentPanelVisible = false;
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide
// PERSISTENT parent associations - agentId -> sessionId
// This is the SINGLE SOURCE OF TRUTH for which tab an agent window connects to.
// Once set, never recalculated. Persisted to localStorage and server.
this.subagentParentMap = new Map();
// Agent Teams tracking
this.teams = new Map(); // Map<teamName, TeamConfig>
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
// Teammate tmux pane terminals (Agent Teams feature)
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
this.ralphStatePanelCollapsed = true; // Default to collapsed
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
// Plan subagent windows (visible agents during plan generation)
this.planSubagents = new Map(); // Map<agentId, { type, model, status, startTime, element, relativePos }>
this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE;
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<sessionId, ActiveBashTool[]>
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE;
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<imageId, { element, sessionId, filePath }>
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
// Tunnel indicator state
this._tunnelUrl = null;
// Tab alert states: Map<sessionId, 'action' | 'idle'>
this.tabAlerts = new Map();
// Pending hooks per session: Map<sessionId, Set<hookType>>
// 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
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
this._loadBufferQueue = null; // queued SSE events during buffer load
// Flicker filter state (buffers output after screen clears)
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
this.flickerFilterTimeout = null;
// Render debouncing
this.renderSessionTabsTimeout = null;
this.renderRalphStatePanelTimeout = null;
this.renderTaskPanelTimeout = null;
this.renderMuxSessionsTimeout = 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;
// SSE connection status tracking
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.isOnline = navigator.onLine;
// Offline input queue
this._inputQueue = new Map(); // Map<sessionId, string>
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
this._connectionStatus = 'connected';
// Sequential input send chain — ensures keystroke ordering across async fetches
this._inputSendChain = Promise.resolve();
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
this._localEchoOverlay = null; // created after terminal.open()
this._localEchoEnabled = false; // true when setting on + session active
this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps
// Accessibility: Focus trap for modals
this.activeFocusTrap = null;
// Notification system
this.notificationManager = new NotificationManager(this);
this.idleTimers = new Map(); // Map<sessionId, timeout> 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 — app bootstrap and mobile setup
// ═══════════════════════════════════════════════════════════════
init() {
// Initialize mobile detection first (adds device classes to body)
MobileDetection.init();
// Initialize mobile handlers
KeyboardHandler.init();
SwipeHandler.init();
VoiceInput.init();
KeyboardAccessoryBar.init();
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// Remove mobile-init class now that JS has applied visibility settings.
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
document.documentElement.classList.remove('mobile-init');
// Defer heavy terminal canvas creation to next frame — lets browser paint header/skeleton first.
// IMPORTANT: connectSSE must run AFTER initTerminal to prevent a race where SSE data
// arrives before the terminal exists, orphaning data in pendingWrites and corrupting
// escape sequence boundaries when later concatenated with fresh data.
requestAnimationFrame(() => {
this.initTerminal();
this.loadFontSize();
this.connectSSE();
// Only fetch state if SSE init event hasn't arrived within 3s (avoids duplicate handleInit)
this._initFallbackTimer = setTimeout(() => {
if (this._initGeneration === 0) this.loadState();
}, 3000);
});
// Register service worker for push notifications
this.registerServiceWorker();
// Fetch tunnel status for header indicator (desktop only)
this.loadTunnelStatus();
// Share a single settings fetch between both consumers
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
this.loadQuickStartCases(null, settingsPromise);
this._initRunMode();
this.setupEventListeners();
// Mobile: ensure button taps register even when keyboard is visible.
// On mobile, tapping a button while the soft keyboard is up causes the
// browser to dismiss the keyboard first (blur event), swallowing the tap.
// The button only receives the click on a second tap. Fix: intercept
// touchstart on buttons while keyboard is visible, preventDefault to stop
// the dismiss-swallows-tap behavior, and trigger the click programmatically.
if (MobileDetection.isTouchDevice()) {
const addKeyboardTapFix = (container) => {
if (!container) return;
container.addEventListener('touchstart', (e) => {
if (!KeyboardHandler.keyboardVisible) return;
const btn = e.target.closest('button');
if (!btn) return;
e.preventDefault();
btn.click();
// Refocus terminal so keyboard stays open (e.g. voice input button)
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
}, { passive: false });
};
addKeyboardTapFix(document.querySelector('.toolbar'));
addKeyboardTapFix(document.querySelector('.welcome-overlay'));
}
// System stats polling deferred until sessions exist (started in handleInit/session:created)
// Setup online/offline detection
this.setupOnlineDetection();
// Load server-stored settings (async, re-applies visibility after load)
this.loadAppSettingsFromServer(settingsPromise).then(() => {
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
});
// Hide loading skeleton now that the app shell is ready
document.body.classList.add('app-loaded');
}
// ═══════════════════════════════════════════════════════════════
// Terminal Setup — xterm.js config and input handling
// ═══════════════════════════════════════════════════════════════
initTerminal() {
// Load scrollback setting from localStorage (default 5000)
const scrollback = parseInt(localStorage.getItem('codeman-scrollback')) || DEFAULT_SCROLLBACK;
this.terminal = new Terminal({
theme: {
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 12,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
scrollback: scrollback,
allowTransparency: true,
allowProposedApi: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
this.terminal.loadAddon(unicode11Addon);
this.terminal.unicode.activeVersion = '11';
} catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ }
}
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
// WebGL renderer for GPU-accelerated terminal rendering.
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
// oversized terminal.write() calls that triggered the stalls.
// Disable with ?nowebgl URL param if GPU issues return.
this._webglAddon = null;
const skipWebGL = MobileDetection.getDeviceType() !== 'desktop';
if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl') && typeof WebglAddon !== 'undefined') {
try {
this._webglAddon = new WebglAddon.WebglAddon();
this._webglAddon.onContextLoss(() => {
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
this._webglAddon.dispose();
this._webglAddon = null;
});
this.terminal.loadAddon(this._webglAddon);
console.log('[CRASH-DIAG] WebGL renderer enabled via ?webgl param');
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
}
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
// On mobile Safari, delay initial fit() to allow layout to settle
// This prevents 0-column terminals caused by fit() running before container is sized
const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' &&
document.body.classList.contains('safari-browser');
if (isMobileSafari) {
// Wait for layout, then fit multiple times to ensure proper sizing
requestAnimationFrame(() => {
this.fitAddon.fit();
// Double-check after another frame
requestAnimationFrame(() => this.fitAddon.fit());
});
} else {
this.fitAddon.fit();
}
// Register link provider for clickable file paths in Bash tool output
this.registerFilePathLinkProvider();
// Always use mouse wheel for terminal scrollback, never forward to application.
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
container.addEventListener('wheel', (ev) => {
ev.preventDefault();
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
this.terminal.scrollLines(lines);
}, { passive: false });
// Touch scrolling - only use custom JS scrolling on desktop
// Mobile uses native browser scrolling via CSS touch-action: pan-y
const isMobileDevice = MobileDetection.isTouchDevice() && window.innerWidth < 1024;
if (!isMobileDevice) {
// Desktop touch scrolling with custom momentum
let touchLastY = 0;
let pendingDelta = 0;
let velocity = 0;
let lastTime = 0;
let scrollFrame = null;
let isTouching = false;
const viewport = container.querySelector('.xterm-viewport');
// Single RAF loop handles both touch and momentum
const scrollLoop = (timestamp) => {
if (!viewport) return;
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; // Normalize to 60fps
lastTime = timestamp;
if (isTouching) {
// During touch: apply pending delta
if (pendingDelta !== 0) {
viewport.scrollTop += pendingDelta;
pendingDelta = 0;
}
scrollFrame = requestAnimationFrame(scrollLoop);
} else if (Math.abs(velocity) > 0.1) {
// Momentum phase
viewport.scrollTop += velocity * dt;
velocity *= 0.94; // Smooth deceleration
scrollFrame = requestAnimationFrame(scrollLoop);
} else {
scrollFrame = null;
velocity = 0;
}
};
container.addEventListener('touchstart', (ev) => {
if (ev.touches.length === 1) {
touchLastY = ev.touches[0].clientY;
pendingDelta = 0;
velocity = 0;
isTouching = true;
lastTime = 0;
if (!scrollFrame) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
}
}, { passive: true });
container.addEventListener('touchmove', (ev) => {
if (ev.touches.length === 1 && isTouching) {
const touchY = ev.touches[0].clientY;
const delta = touchLastY - touchY;
pendingDelta += delta;
velocity = delta * 1.2; // Track for momentum
touchLastY = touchY;
}
}, { passive: true });
container.addEventListener('touchend', () => {
isTouching = false;
// Momentum continues in scrollLoop
}, { passive: true });
container.addEventListener('touchcancel', () => {
isTouching = false;
velocity = 0;
}, { passive: true });
}
// Mobile: native scrolling handles touch via CSS
// Welcome message
this.showWelcome();
// Handle resize with throttling for performance
this._resizeTimeout = null;
this._lastResizeDims = null;
// Minimum terminal dimensions to prevent vertical text wrapping
const MIN_COLS = 40;
const MIN_ROWS = 10;
const throttledResize = () => {
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
// once after the user stops resizing. During active resize, the terminal
// stays at its old dimensions for up to 300ms.
//
// Why not fit() immediately? Each fitAddon.fit() reflows content at the
// new width — lines that were 7 rows become 10, and the overflow gets
// pushed into scrollback. With continuous resize events, this creates
// dozens of intermediate reflow states in scrollback, appearing as
// duplicate/garbled content when the user scrolls up.
//
// By deferring fit() to the trailing edge, there's exactly ONE reflow
// at the final dimensions, ONE viewport clear, and ONE Ink redraw.
if (this._resizeTimeout) {
clearTimeout(this._resizeTimeout);
}
this._resizeTimeout = setTimeout(() => {
this._resizeTimeout = null;
// Fit xterm.js to final container dimensions
if (this.fitAddon) {
this.fitAddon.fit();
}
// Flush any stale flicker buffer before clearing viewport
if (this.flickerFilterBuffer) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flushFlickerBuffer();
}
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
// pushing overflow into scrollback. Ink's cursor-up count is based on the
// pre-reflow line count, so ghost renders accumulate in scrollback.
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
&& this.terminal && this.isTerminalAtBottom()) {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
if (this.activeSessionId && !keyboardUp) {
const dims = this.fitAddon.proposeDimensions();
// Enforce minimum dimensions to prevent layout issues
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
// Only send resize if dimensions actually changed
if (!this._lastResizeDims ||
cols !== this._lastResizeDims.cols ||
rows !== this._lastResizeDims.rows) {
this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows })
}).catch(() => {});
}
}
// Update subagent connection lines and local echo at new dimensions
this.updateConnectionLines();
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
}, 300); // Trailing-edge: only fire after 300ms of no resize events
};
window.addEventListener('resize', throttledResize);
// Store resize observer for cleanup (prevents memory leak on terminal re-init)
if (this.terminalResizeObserver) {
this.terminalResizeObserver.disconnect();
}
this.terminalResizeObserver = new ResizeObserver(throttledResize);
this.terminalResizeObserver.observe(container);
// Handle keyboard input — send to PTY immediately, no local echo.
// PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug).
this._pendingInput = '';
this._inputFlushTimeout = null;
this._lastKeystrokeTime = 0;
const flushInput = () => {
this._inputFlushTimeout = null;
if (this._pendingInput && this.activeSessionId) {
const input = this._pendingInput;
const sessionId = this.activeSessionId;
this._pendingInput = '';
this._sendInputAsync(sessionId, input);
}
};
// Local echo mode: buffer keystrokes locally (shown in overlay) and only
// send to PTY on Enter. Avoids out-of-order delivery on high-latency
// mobile connections. The overlay + localStorage persistence ensure input
// survives tab switches and reconnects.
this.terminal.onData((data) => {
if (this.activeSessionId) {
// Filter out terminal query responses that xterm.js generates automatically.
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
// sent by tmux when attaching. Without this filter, they appear as typed text.
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
// ── Local Echo Mode ──
// When enabled, keystrokes are buffered locally in the overlay for
// instant visual feedback. Nothing is sent to the PTY until Enter
// (or a control char) is pressed — avoids out-of-order char delivery.
if (this._localEchoEnabled) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
if (source === 'flushed') {
// Sync app-level flushed Maps (per-session state for tab switching)
const { count, text } = this._localEchoOverlay.getFlushed();
if (this._flushedOffsets?.has(this.activeSessionId)) {
if (count === 0) {
this._flushedOffsets.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
} else {
this._flushedOffsets.set(this.activeSessionId, count);
this._flushedTexts?.set(this.activeSessionId, text);
}
}
this._pendingInput += data;
flushInput();
}
// 'pending' = removed unsent text (no PTY backspace needed)
// false = nothing to remove (swallow the backspace)
return;
}
if (/^[\r\n]+$/.test(data)) {
// Enter: send full buffered text + \r to PTY in one shot
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
// Suppress detection so PTY-echoed text isn't re-detected as user input
this._localEchoOverlay?.suppressBufferDetection();
// Clear flushed offset and text — Enter commits all text
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
if (text) {
this._pendingInput += text;
flushInput();
}
// Send \r after a short delay so text arrives first
setTimeout(() => {
this._pendingInput += '\r';
flushInput();
}, 80);
return;
}
if (data.length > 1 && data.charCodeAt(0) >= 32) {
// Paste: append to overlay only (sent on Enter)
this._localEchoOverlay?.appendText(data);
return;
}
if (data.charCodeAt(0) < 32) {
// Skip xterm-generated terminal responses.
// These arrive via triggerDataEvent when the terminal processes
// buffer data (DA responses, OSC color queries, mode reports, etc.).
// They are NOT user input and must not clear flushed text state.
// Covers: CSI (\x1b[), OSC (\x1b]), DCS (\x1bP), APC (\x1b_),
// PM (\x1b^), SOS (\x1bX), and any other multi-byte ESC sequence.
// Single-byte ESC (user pressing Escape) still falls through to
// the control char handler below.
if (data.length > 1 && data.charCodeAt(0) === 27) {
// Multi-byte escape sequence — forward to PTY without clearing
// overlay/flushed state (terminal response, not user input)
this._pendingInput += data;
flushInput();
return;
}
// During buffer load (tab switch), stray control chars from
// terminal response processing must not wipe the flushed state
// that selectSession() is actively restoring.
if (this._restoringFlushedState) {
this._pendingInput += data;
flushInput();
return;
}
// Tab key: send pending text + Tab to PTY for tab completion.
// Set a flag so flushPendingWrites() re-detects buffer text when
// the PTY response arrives (event-driven, no fixed timer).
if (data === '\t') {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (text) {
this._pendingInput += text;
}
this._pendingInput += data;
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
// Snapshot prompt line text BEFORE flushing — used to distinguish
// real Tab completions from pre-existing Claude UI text.
let baseText = '';
try {
const p = this._localEchoOverlay?.findPrompt?.();
if (p) {
const buf = this.terminal.buffer.active;
const line = buf.getLine(buf.viewportY + p.row);
if (line) baseText = line.translateToString(true).slice(p.col + 2).trimEnd();
}
} catch {}
this._tabCompletionBaseText = baseText;
flushInput();
this._tabCompletionSessionId = this.activeSessionId;
this._tabCompletionRetries = 0;
// Fallback: if flushPendingWrites() detection misses the completion
// (e.g., flicker filter delays data, or xterm hasn't processed writes
// by the time the callback fires), retry detection after a delay.
// This ensures the overlay renders even without further terminal data.
if (this._tabCompletionFallback) clearTimeout(this._tabCompletionFallback);
const selfTab = this;
this._tabCompletionFallback = setTimeout(() => {
selfTab._tabCompletionFallback = null;
if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return;
const ov = selfTab._localEchoOverlay;
if (!ov || ov.pendingText) return;
selfTab.terminal.write('', () => {
if (!selfTab._tabCompletionSessionId) return;
ov.resetBufferDetection();
const detected = ov.detectBufferText();
if (detected && detected !== selfTab._tabCompletionBaseText) {
selfTab._tabCompletionSessionId = null;
selfTab._tabCompletionRetries = 0;
selfTab._tabCompletionBaseText = null;
ov.rerender();
}
});
}, 300);
return;
}
// Control chars (Ctrl+C, single ESC): send buffered text + control char immediately
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
// Suppress detection so PTY-echoed text isn't re-detected as user input
this._localEchoOverlay?.suppressBufferDetection();
// Clear flushed offset and text — control chars (Ctrl+C, Escape) change
// cursor position or abort readline, making flushed text tracking invalid.
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (text) {
this._pendingInput += text;
}
this._pendingInput += data;
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
flushInput();
return;
}
if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable char: add to overlay only (sent on Enter)
this._localEchoOverlay?.addChar(data);
return;
}
}
// ── Normal Mode (echo disabled) ──
this._pendingInput += data;
// Control chars (Enter, Ctrl+C, escape sequences) — flush immediately
if (data.charCodeAt(0) < 32 || data.length > 1) {
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
flushInput();
return;
}
// Regular chars — flush immediately if typed after a gap (>50ms),
// otherwise batch via microtask to coalesce rapid keystrokes (paste).
const now = performance.now();
if (now - this._lastKeystrokeTime > 50) {
// Single char after a gap — send immediately, no setTimeout latency
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
this._lastKeystrokeTime = now;
flushInput();
} else {
// Rapid sequence (paste or fast typing) — coalesce via microtask
this._lastKeystrokeTime = now;
if (!this._inputFlushTimeout) {
this._inputFlushTimeout = setTimeout(flushInput, 0);
}
}
}
});
}
/**
* Register a custom link provider for xterm.js that detects file paths
* in terminal output and makes them clickable.
* When clicked, opens a floating log viewer window with live streaming.
*/
registerFilePathLinkProvider() {
const self = this;
// Debug: Track if provider is being invoked
let lastInvokedLine = -1;
this.terminal.registerLinkProvider({
provideLinks(bufferLineNumber, callback) {
// Debug logging - only log if line changed to avoid spam
if (bufferLineNumber !== lastInvokedLine) {
lastInvokedLine = bufferLineNumber;
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
}
const buffer = self.terminal.buffer.active;
const line = buffer.getLine(bufferLineNumber);
if (!line) {
callback(undefined);
return;
}
// Get line text - translateToString handles wrapped lines
const lineText = line.translateToString(true);
if (!lineText || !lineText.includes('/')) {
callback(undefined);
return;
}
const links = [];
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
// Handles: tail -f /path, grep pattern /path, cat -n /path
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
const addLink = (filePath, matchIndex) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
// Skip if already have link at this position
if (links.some(l => l.range.start.x === startCol + 1)) return;
links.push({
text: filePath,
range: {
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
end: { x: startCol + filePath.length + 1, y: bufferLineNumber }
},
decorations: {
pointerCursor: true,
underline: true
},
activate(event, text) {
self.openLogViewerWindow(text, self.activeSessionId);
}
});
};
// Match all patterns
let match;
cmdPattern.lastIndex = 0;
while ((match = cmdPattern.exec(lineText)) !== null) {
addLink(match[2], match.index);
}
extPattern.lastIndex = 0;
while ((match = extPattern.exec(lineText)) !== null) {
addLink(match[1], match.index);
}
bashPattern.lastIndex = 0;
while ((match = bashPattern.exec(lineText)) !== null) {
addLink(match[1], match.index);
}
if (links.length > 0) {
console.debug('[LinkProvider] Found links:', links.map(l => l.text));
}
callback(links.length > 0 ? links : undefined);
}
});
console.log('[LinkProvider] File path link provider registered');
}
showWelcome() {
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.add('visible');
this.loadTunnelStatus();
this.loadHistorySessions();
}
}
hideWelcome() {
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.remove('visible');
}
// Collapse expanded QR when leaving welcome screen
const qrWrap = document.getElementById('welcomeQr');
if (qrWrap) {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('expanded');
}
}
async loadHistorySessions() {
const container = document.getElementById('historySessions');
const list = document.getElementById('historyList');
if (!container || !list) return;
try {
const res = await fetch('/api/history/sessions');
const data = await res.json();
const sessions = data.sessions || [];
if (sessions.length === 0) {
container.style.display = 'none';
return;
}
// Deduplicate: keep only the most recent session per workingDir
const byDir = new Map();
for (const s of sessions) {
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
byDir.get(s.workingDir).push(s);
}
// Flatten: show up to 2 most recent per dir, max 12 total
const items = [];
for (const [, group] of byDir) {
items.push(...group.slice(0, 2));
}
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
const display = items.slice(0, 12);
// Build DOM safely (no innerHTML with user data)
list.replaceChildren();
for (const s of display) {
const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B`
: s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K`
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
const date = new Date(s.lastModified);
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
const item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
const dirSpan = document.createElement('span');
dirSpan.className = 'history-item-dir';
dirSpan.textContent = shortDir;
const metaSpan = document.createElement('span');
metaSpan.className = 'history-item-meta';
metaSpan.textContent = timeStr;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'history-item-size';
sizeSpan.textContent = size;
item.append(dirSpan, metaSpan, sizeSpan);
list.appendChild(item);
}
container.style.display = '';
} catch (err) {
console.error('[loadHistorySessions]', err);
container.style.display = 'none';
}
}
async resumeHistorySession(sessionId, workingDir) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
try {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
// Generate a session name from the working dir
const dirName = workingDir.split('/').pop() || 'session';
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-/);
if (match) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
const name = `w${startNumber}-${dirName}`;
// Create session with resumeSessionId
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId })
});
const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error);
const newSessionId = createData.session.id;
// Start interactive
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`);
await this.selectSession(newSessionId);
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
}
// ═══════════════════════════════════════════════════════════════
// Terminal Rendering
// ═══════════════════════════════════════════════════════════════
/**
* Check if terminal viewport is at or near the bottom.
* Used to implement "sticky scroll" behavior - keep user at bottom if they were there.
*/
isTerminalAtBottom() {
if (!this.terminal) return true;
const buffer = this.terminal.buffer.active;
// viewportY is the top line of the viewport, baseY is where scrollback starts
// If viewportY >= baseY, we're showing the latest content (at bottom)
// Allow 2 lines tolerance for edge cases
return buffer.viewportY >= buffer.baseY - 2;
}
batchTerminalWrite(data) {
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data.
// This is critical: interleaving causes cursor position chaos with Ink redraws.
if (this._isLoadingBuffer) {
if (this._loadBufferQueue) this._loadBufferQueue.push(data);
return;
}
// Check if at bottom BEFORE adding data (captures user's scroll position)
// Only update if not already scheduled (preserve the first check's result)
if (!this.writeFrameScheduled) {
this._wasAtBottomBeforeWrite = this.isTerminalAtBottom();
}
// Check if flicker filter is enabled for current session
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const flickerFilterEnabled = session?.flickerFilterEnabled ?? false;
// xterm.js 6.0 handles DEC 2026 synchronized output natively — Ink's cursor-up
// redraws are wrapped in 2026h/2026l markers and rendered atomically by xterm.js.
// No client-side cursor-up detection/buffering needed. The old 50ms flicker filter
// was actively harmful: it accumulated multiple resize redraws and flushed them
// together, causing stacked ghost renders due to reflow line-count mismatches.
// Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it)
if (flickerFilterEnabled) {
const hasScreenClear = data.includes('\x1b[2J') ||
data.includes('\x1b[H\x1b[J') ||
(data.includes('\x1b[H') && data.includes('\x1b[?25l'));
if (hasScreenClear) {
this.flickerFilterActive = true;
this.flickerFilterBuffer += data;
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
}
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window
return;
}
if (this.flickerFilterActive) {
this.flickerFilterBuffer += data;
return;
}
}
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
// content between 2026h/2026l and renders atomically. No need for
// client-side incomplete-block detection; just flush every frame.
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
}
}
/**
* Flush the flicker filter buffer to the terminal.
* Called after the buffer window expires.
*/
flushFlickerBuffer() {
if (!this.flickerFilterBuffer) return;
// Transfer buffered data to normal pending writes
this.pendingWrites.push(this.flickerFilterBuffer);
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
// Trigger a normal flush
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
}
}
/**
* Update local echo overlay state based on settings.
* Enabled whenever the setting is on — works during idle AND busy.
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
const shouldEnable = !!(echoEnabled && session);
if (this._localEchoEnabled && !shouldEnable) {
this._localEchoOverlay?.clear();
}
this._localEchoEnabled = shouldEnable;
// Swap prompt finder based on session mode
if (this._localEchoOverlay && session) {
if (session.mode === 'opencode') {
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 3,
find: (terminal) => {
try {
const buf = terminal.buffer.active;
const row = buf.cursorY;
const line = buf.getLine(buf.viewportY + row);
if (!line) return null;
const text = line.translateToString(true);
const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL)
if (idx >= 0) return { row, col: idx };
return null;
} catch { return null; }
}
});
} else if (session.mode === 'shell') {
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
// Disable it by clearing any pending text.
this._localEchoOverlay.clear();
this._localEchoEnabled = false;
} else {
// Claude Code: scan for ❯ prompt character
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
}
}
}
/**
* Flush pending writes to terminal, processing DEC 2026 sync markers.
* Strips markers and writes content atomically within a single frame.
*/
flushPendingWrites() {
if (this.pendingWrites.length === 0 || !this.terminal) return;
const _t0 = performance.now();
// xterm.js 6.0+ natively handles DEC 2026 synchronized output markers.
// Pass raw data through — xterm.js buffers content between markers and
// renders atomically, eliminating split-frame Ink redraws.
const joined = this.pendingWrites.join('');
this.pendingWrites = [];
const _joinedLen = joined.length;
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
// Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame
let deferred = false;
if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined);
} else {
// Write first chunk now, defer rest to next frame
this.terminal.write(joined.slice(0, MAX_FRAME_BYTES));
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
}
}
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0;
if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`);
// Sticky scroll: if user was at bottom, keep them there after new output
if (this._wasAtBottomBeforeWrite) {
this.terminal.scrollToBottom();
}
// Re-position local echo overlay after terminal writes — Ink redraws can
// move the ❯ prompt to a different row, making the overlay invisible.
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// After Tab completion: detect the completed text in the overlay.
// Use terminal.write('', callback) to defer detection until xterm.js
// finishes processing ALL queued writes — direct buffer reads after
// terminal.write(data) can miss text if xterm processes asynchronously.
if (this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId
&& this._localEchoOverlay && !this._localEchoOverlay.pendingText) {
const overlay = this._localEchoOverlay;
const self = this;
this.terminal.write('', () => {
if (!self._tabCompletionSessionId) return; // already resolved
overlay.resetBufferDetection();
const detected = overlay.detectBufferText();
if (detected) {
if (detected === self._tabCompletionBaseText) {
// Same text as before Tab — no completion yet. Undo and retry.
overlay.undoDetection();
self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1;
if (self._tabCompletionRetries > 60) {
self._tabCompletionSessionId = null;
self._tabCompletionRetries = 0;
}
} else {
// Text changed — real completion happened
self._tabCompletionSessionId = null;
self._tabCompletionRetries = 0;
self._tabCompletionBaseText = null;
if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; }
overlay.rerender();
}
} else {
// No text found yet — retry on next flush.
self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1;
if (self._tabCompletionRetries > 60) {
self._tabCompletionSessionId = null;
self._tabCompletionRetries = 0;
}
}
});
}
}
/**
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses requestAnimationFrame to spread work across frames.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
this._finishBufferLoad();
resolve();
return;
}
// Block live SSE writes during buffer load to prevent interleaving
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
// Strip any DEC 2026 markers that might be in the buffer
// (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
const finish = () => {
this._finishBufferLoad();
resolve();
};
// For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer);
finish();
return;
}
// Large buffers: write in chunks across animation frames.
// Each 32KB chunk keeps per-frame WebGL render work under ~5ms,
// avoiding GPU stalls without needing to toggle the renderer.
let offset = 0;
const _chunkStart = performance.now();
let _chunkCount = 0;
const writeChunk = () => {
if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart;
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
// Wait one more frame for xterm to finish rendering before resolving
requestAnimationFrame(finish);
return;
}
const _ct0 = performance.now();
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
this.terminal.write(chunk);
const _cdt = performance.now() - _ct0;
_chunkCount++;
if (_cdt > 50) console.warn(`[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})`);
offset += chunkSize;
// Schedule next chunk on next frame
requestAnimationFrame(writeChunk);
};
// Start writing
requestAnimationFrame(writeChunk);
});
}
/**
* Complete a buffer load: unblock live SSE writes and flush any queued events.
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
*/
_finishBufferLoad() {
const queue = this._loadBufferQueue;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
if (queue && queue.length > 0) {
for (const data of queue) {
this.batchTerminalWrite(data);
}
}
}
// ═══════════════════════════════════════════════════════════════
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
// ═══════════════════════════════════════════════════════════════
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 + Shift + R - restore terminal size (after mobile squeeze)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'R') {
e.preventDefault();
this.restoreTerminalSize();
}
// 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();
}
// Ctrl/Cmd + Shift + V - toggle voice input
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'V') {
e.preventDefault();
VoiceInput.toggle();
}
}, true); // Use capture phase to handle before terminal
// Token stats click handler (with guard to prevent duplicate handlers on reconnect)
const tokenEl = this.$('headerTokens');
if (tokenEl && !tokenEl._statsHandlerAttached) {
tokenEl.classList.add('clickable');
tokenEl._statsHandlerAttached = true;
tokenEl.addEventListener('click', () => this.openTokenStats());
}
// Color picker for session customization
this.setupColorPicker();
}
// ═══════════════════════════════════════════════════════════════
// SSE Connection
// ═══════════════════════════════════════════════════════════════
connectSSE() {
// Check if browser is offline
if (!navigator.onLine) {
this.setConnectionStatus('offline');
return;
}
// 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;
}
// Show connecting state
if (this.reconnectAttempts === 0) {
this.setConnectionStatus('connecting');
} else {
this.setConnectionStatus('reconnecting');
}
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.reconnectAttempts = 0;
this.setConnectionStatus('connected');
};
this.eventSource.onerror = () => {
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.setConnectionStatus('disconnected');
} else {
this.setConnectionStatus('reconnecting');
}
// 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);
}
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
const delay = this.reconnectAttempts <= 1 ? 200
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
};
// Create stable handler wrappers once (reused across reconnects so
// removeEventListener always matches the original reference)
if (!this._sseHandlerWrappers) {
this._sseHandlerWrappers = new Map();
for (const [event, method] of _SSE_HANDLER_MAP) {
const fn = this[method];
this._sseHandlerWrappers.set(event, (e) => {
try {
fn.call(this, e.data ? JSON.parse(e.data) : {});
} catch (err) {
console.error(`[SSE] Error handling ${event}:`, err);
}
});
}
}
// Register all SSE event handlers via centralized map
for (const [event] of _SSE_HANDLER_MAP) {
addListener(event, this._sseHandlerWrappers.get(event));
}
}
// ═══════════════════════════════════════════════════════════════
// SSE Event Handlers
// ═══════════════════════════════════════════════════════════════
// Each _on* method receives pre-parsed SSE data (JSON.parse done in connectSSE loop).
// Async handlers have their own internal try/catch for fetch errors.
_onInit(data) {
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
this.handleInit(data);
}
_onSessionCreated(data) {
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
this.sessionOrder.push(data.id);
this.saveSessionOrder();
}
this.renderSessionTabs();
this.updateCost();
// Start stats polling when first session appears
if (this.sessions.size === 1) this.startSystemStatsPolling();
}
_onSessionUpdated(data) {
const session = data.session || data;
const oldSession = this.sessions.get(session.id);
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
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);
// If claudeSessionId was just set, re-check orphan subagents
// This connects subagents that were waiting for the session to identify itself
if (claudeSessionIdJustSet) {
this.recheckOrphanSubagents();
// Update connection lines after DOM settles (ensure tabs are rendered)
requestAnimationFrame(() => {
this.updateConnectionLines();
});
}
}
_onSessionDeleted(data) {
this._cleanupSessionData(data.id);
if (this.activeSessionId === data.id) {
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.terminal.clear();
this.showWelcome();
}
this.renderSessionTabs();
this.renderRalphStatePanel(); // Update ralph panel after session deleted
this.renderProjectInsightsPanel(); // Update project insights panel after session deleted
// Stop stats polling when no sessions remain
if (this.sessions.size === 0) this.stopSystemStatsPolling();
}
_onSessionTerminal(data) {
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
// Hard cap: track total bytes queued in render buffers (pendingWrites +
// flickerFilterBuffer). When rAF is throttled (tab
// backgrounded, GPU busy), data accumulates with no flush, reaching
// 889KB+ and freezing Chrome for minutes. Drop data beyond 128KB and
// schedule a buffer reload to recover the display once the burst subsides.
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
+ (this.flickerFilterBuffer?.length || 0);
if (queued > 131072) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery: reload the full terminal buffer once the
// queue drains (debounced to avoid hammering the API during sustained bursts).
if (!this._clientDropRecoveryTimer) {
this._clientDropRecoveryTimer = setTimeout(() => {
this._clientDropRecoveryTimer = null;
this._onSessionNeedsRefresh();
}, 2000);
}
return;
}
this.batchTerminalWrite(data.data);
}
}
async _onSessionNeedsRefresh() {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
if (!this.activeSessionId || !this.terminal) return;
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
const data = await res.json();
if (data.terminalBuffer) {
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(data.terminalBuffer);
this.terminal.scrollToBottom();
// Re-position local echo overlay at new prompt location
this._localEchoOverlay?.rerender();
// Resize PTY to match actual browser dimensions (critical for OpenCode
// TUI sessions that render at fixed 120x40 until told the real size)
if (this.activeSessionId) {
this.sendResize(this.activeSessionId);
}
}
} catch (err) {
console.error('needsRefresh reload failed:', err);
}
}
async _onSessionClearTerminal(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.replace(DEC_SYNC_STRIP_RE, '');
// Use chunked write to avoid UI freeze with large buffers (can be 1-2MB)
await this.chunkedTerminalWrite(cleanBuffer);
}
// Fire-and-forget resize — don't block on it
this.sendResize(data.id);
// Re-position local echo overlay at new prompt location
this._localEchoOverlay?.rerender();
} catch (err) {
console.error('clearTerminal refresh failed:', err);
}
}
}
_onSessionCompletion(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`);
}
}
_onSessionError(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 || this.getShortId(data.id),
title: 'Session Error',
message: data.error || 'Unknown error',
});
}
_onSessionExit(data) {
const session = this.sessions.get(data.id);
if (session) {
session.status = 'stopped';
this.renderSessionTabs();
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// 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 || this.getShortId(data.id),
title: 'Session Crashed',
message: `Exited with code ${data.code}`,
});
}
}
_onSessionIdle(data) {
const session = this.sessions.get(data.id);
if (session) {
session.status = 'idle';
this.renderSessionTabs();
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// 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 || this.getShortId(data.id),
title: 'Session Idle',
message: `Idle for ${Math.round(threshold / 60000)}+ minutes`,
});
this.idleTimers.delete(data.id);
}, threshold));
}
}
_onSessionWorking(data) {
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);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Clear stuck detection timer
const timer = this.idleTimers.get(data.id);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(data.id);
}
}
_onSessionAutoClear(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 || this.getShortId(data.sessionId),
title: 'Auto-Cleared',
message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`,
});
}
_onSessionCliInfo(data) {
const session = this.sessions.get(data.sessionId);
if (session) {
if (data.version) session.cliVersion = data.version;
if (data.model) session.cliModel = data.model;
if (data.accountType) session.cliAccountType = data.accountType;
if (data.latestVersion) session.cliLatestVersion = data.latestVersion;
}
if (data.sessionId === this.activeSessionId) {
this.updateCliInfoDisplay();
}
}
// Scheduled runs
_onScheduledCreated(data) {
this.currentRun = data;
this.showTimer();
}
_onScheduledUpdated(data) {
this.currentRun = data;
this.updateTimer();
}
_onScheduledCompleted(data) {
this.currentRun = data;
this.hideTimer();
this.showToast('Scheduled run completed!', 'success');
}
_onScheduledStopped() {
this.currentRun = null;
this.hideTimer();
}
// Respawn
_onRespawnStarted(data) {
this.respawnStatus[data.sessionId] = data.status;
if (data.sessionId === this.activeSessionId) {
this.showRespawnBanner();
}
}
_onRespawnStopped(data) {
delete this.respawnStatus[data.sessionId];
if (data.sessionId === this.activeSessionId) {
this.hideRespawnBanner();
}
}
_onRespawnStateChanged(data) {
if (this.respawnStatus[data.sessionId]) {
this.respawnStatus[data.sessionId].state = data.state;
}
if (data.sessionId === this.activeSessionId) {
this.updateRespawnBanner(data.state);
}
}
_onRespawnCycleStarted(data) {
if (this.respawnStatus[data.sessionId]) {
this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber;
}
if (data.sessionId === this.activeSessionId) {
document.getElementById('respawnCycleCount').textContent = data.cycleNumber;
}
}
_onRespawnBlocked(data) {
const session = this.sessions.get(data.sessionId);
const reasonMap = {
circuit_breaker_open: 'Circuit Breaker Open',
exit_signal: 'Exit Signal Detected',
status_blocked: 'Claude Reported BLOCKED',
};
const title = reasonMap[data.reason] || 'Respawn Blocked';
this.notificationManager?.notify({
urgency: 'critical',
category: 'respawn-blocked',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title,
message: data.details,
});
// Update respawn panel to show blocked state
if (data.sessionId === this.activeSessionId) {
const stateEl = document.getElementById('respawnStateLabel');
if (stateEl) {
stateEl.textContent = title;
stateEl.classList.add('respawn-blocked');
}
}
}
_onRespawnAutoAcceptSent(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'auto-accept',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Plan Accepted',
message: `Accepted plan mode for ${session?.name || 'session'}`,
});
}
_onRespawnDetectionUpdate(data) {
if (this.respawnStatus[data.sessionId]) {
this.respawnStatus[data.sessionId].detection = data.detection;
}
if (data.sessionId === this.activeSessionId) {
this.updateDetectionDisplay(data.detection);
}
}
// Merged handler for respawn:timerStarted — handles both run timers (data.endAt)
// and controller countdown timers (data.timer). Previously registered as two
// separate addListener calls (duplicate event bug).
_onRespawnTimerStarted(data) {
// Run timer (timed respawn runs)
if (data.endAt) {
this.respawnTimers[data.sessionId] = {
endAt: data.endAt,
startedAt: data.startedAt,
durationMinutes: data.durationMinutes
};
if (data.sessionId === this.activeSessionId) {
this.showRespawnTimer();
}
}
// Controller countdown timer (internal timers)
if (data.timer) {
const { sessionId, timer } = data;
if (!this.respawnCountdownTimers[sessionId]) {
this.respawnCountdownTimers[sessionId] = {};
}
this.respawnCountdownTimers[sessionId][timer.name] = {
endsAt: timer.endsAt,
totalMs: timer.durationMs,
reason: timer.reason
};
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
this.startCountdownInterval();
}
}
}
_onRespawnTimerCancelled(data) {
const { sessionId, timerName } = data;
if (this.respawnCountdownTimers[sessionId]) {
delete this.respawnCountdownTimers[sessionId][timerName];
}
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
}
}
_onRespawnTimerCompleted(data) {
const { sessionId, timerName } = data;
if (this.respawnCountdownTimers[sessionId]) {
delete this.respawnCountdownTimers[sessionId][timerName];
}
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
}
}
_onRespawnError(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'critical',
category: 'session-error',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Respawn Error',
message: data.error || data.message || 'Respawn encountered an error',
});
}
_onRespawnActionLog(data) {
const { sessionId, action } = data;
this.addActionLogEntry(sessionId, action);
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay(); // Show row if hidden
this.updateActionLogDisplay();
}
}
// Tasks
_onTaskCreated(data) {
this.renderSessionTabs();
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
}
_onTaskCompleted(data) {
this.renderSessionTabs();
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
}
_onTaskFailed(data) {
this.renderSessionTabs();
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
}
_onTaskUpdated(data) {
if (data.sessionId === this.activeSessionId) {
this.renderTaskPanel();
}
}
// Mux (tmux)
_onMuxCreated(data) {
this.muxSessions.push(data);
this.renderMuxSessions();
}
_onMuxKilled(data) {
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
this.renderMuxSessions();
}
_onMuxDied(data) {
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId);
this.renderMuxSessions();
this.showToast('Mux session died: ' + this.getShortId(data.sessionId), 'warning');
}
_onMuxStatsUpdated(data) {
this.muxSessions = data;
if (document.getElementById('monitorPanel').classList.contains('open')) {
this.renderMuxSessions();
}
}
// Ralph
_onRalphLoopUpdate(data) {
// Skip if user explicitly closed this session's Ralph panel
if (this.ralphClosedSessions.has(data.sessionId)) return;
this.updateRalphState(data.sessionId, { loop: data.state });
}
_onRalphTodoUpdate(data) {
// Skip if user explicitly closed this session's Ralph panel
if (this.ralphClosedSessions.has(data.sessionId)) return;
this.updateRalphState(data.sessionId, { todos: data.todos });
}
_onRalphCompletionDetected(data) {
// Skip if user explicitly closed this session's Ralph panel
if (this.ralphClosedSessions.has(data.sessionId)) return;
// Prevent duplicate notifications for the same completion
const completionKey = `${data.sessionId}:${data.phrase}`;
if (this._shownCompletions?.has(completionKey)) {
return;
}
if (!this._shownCompletions) {
this._shownCompletions = new Set();
}
this._shownCompletions.add(completionKey);
// Clear after 30 seconds to allow re-notification if loop restarts
setTimeout(() => this._shownCompletions?.delete(completionKey), 30000);
// Update ralph state to mark loop as inactive
const existing = this.ralphStates.get(data.sessionId) || {};
if (existing.loop) {
existing.loop.active = false;
this.updateRalphState(data.sessionId, existing);
}
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'ralph-complete',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Loop Complete',
message: `Completion: ${data.phrase || 'unknown'}`,
});
}
_onRalphStatusUpdate(data) {
// Skip if user explicitly closed this session's Ralph panel
if (this.ralphClosedSessions.has(data.sessionId)) return;
this.updateRalphState(data.sessionId, { statusBlock: data.block });
}
_onCircuitBreakerUpdate(data) {
// Skip if user explicitly closed this session's Ralph panel
if (this.ralphClosedSessions.has(data.sessionId)) return;
this.updateRalphState(data.sessionId, { circuitBreaker: data.status });
// Notify if circuit breaker opens
if (data.status.state === 'OPEN') {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'critical',
category: 'circuit-breaker',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Circuit Breaker Open',
message: data.status.reason || 'Loop stuck - no progress detected',
});
}
}
_onExitGateMet(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'exit-gate',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Exit Gate Met',
message: `Loop ready to exit (indicators: ${data.completionIndicators})`,
});
}
// Bash tools
_onBashToolStart(data) {
this.handleBashToolStart(data.sessionId, data.tool);
}
_onBashToolEnd(data) {
this.handleBashToolEnd(data.sessionId, data.tool);
}
_onBashToolsUpdate(data) {
this.handleBashToolsUpdate(data.sessionId, data.tools);
}
// Hooks (Claude Code hook events)
_onHookIdlePrompt(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - alert will show when switching away from session
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'idle_prompt');
}
this.notificationManager?.notify({
urgency: 'warning',
category: 'hook-idle',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Waiting for Input',
message: data.message || 'Claude is idle and waiting for a prompt',
});
}
_onHookPermissionPrompt(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - action alerts need user interaction to clear
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'permission_prompt');
}
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
this.notificationManager?.notify({
urgency: 'critical',
category: 'hook-permission',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Permission Required',
message: toolInfo || 'Claude needs tool approval to continue',
});
}
_onHookElicitationDialog(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - action alerts need user interaction to clear
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'elicitation_dialog');
}
this.notificationManager?.notify({
urgency: 'critical',
category: 'hook-elicitation',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Question Asked',
message: data.question || 'Claude is asking a question and waiting for your answer',
});
}
_onHookStop(data) {
const session = this.sessions.get(data.sessionId);
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
this.clearPendingHooks(data.sessionId);
}
this.notificationManager?.notify({
urgency: 'info',
category: 'hook-stop',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Response Complete',
message: data.reason || 'Claude has finished responding',
});
}
_onHookTeammateIdle(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'hook-teammate-idle',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Teammate Idle',
message: `A teammate is idle in ${session?.name || data.sessionId}`,
});
}
_onHookTaskCompleted(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'hook-task-completed',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Task Completed',
message: `A team task completed in ${session?.name || data.sessionId}`,
});
}
// Subagents (Claude Code background agents)
_onSubagentDiscovered(data) {
// Clear all old data for this agentId (in case of ID reuse)
this.subagents.set(data.agentId, data);
this.subagentActivity.set(data.agentId, []);
this.subagentToolResults.delete(data.agentId);
// Close any existing window for this agentId (will be reopened fresh)
if (this.subagentWindows.has(data.agentId)) {
this.forceCloseSubagentWindow(data.agentId);
}
this.renderSubagentPanel();
// Find which Codeman session owns this subagent (direct claudeSessionId match only)
this.findParentSessionForSubagent(data.agentId);
// Auto-open window for new active agents — but ONLY if they belong to a Codeman session tab.
// Agents from external Claude sessions (not managed by Codeman) should not pop up.
if (data.status === 'active') {
const agentForCheck = this.subagents.get(data.agentId);
const hasMatchingTab = agentForCheck?.sessionId &&
Array.from(this.sessions.values()).some(s => s.claudeSessionId === agentForCheck.sessionId);
if (hasMatchingTab) {
this.openSubagentWindow(data.agentId);
}
}
// Ensure connection lines are updated after window is created and DOM settles
requestAnimationFrame(() => {
this.updateConnectionLines();
});
// Notify about new subagent discovery
const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null;
this.notificationManager?.notify({
urgency: 'info',
category: 'subagent-spawn',
sessionId: parentId || data.sessionId,
sessionName: parentSession?.name || parentId || data.sessionId,
title: 'Subagent Spawned',
message: data.description || 'New background agent started',
});
}
_onSubagentUpdated(data) {
const existing = this.subagents.get(data.agentId);
if (existing) {
// Merge updated fields (especially description)
Object.assign(existing, data);
this.subagents.set(data.agentId, existing);
} else {
this.subagents.set(data.agentId, data);
}
this.renderSubagentPanel();
// Update floating window if open (content + header/title)
if (this.subagentWindows.has(data.agentId)) {
this.renderSubagentWindowContent(data.agentId);
this.updateSubagentWindowHeader(data.agentId);
}
}
_onSubagentToolCall(data) {
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'tool', ...data });
if (activity.length > 50) activity.shift(); // Keep last 50 entries
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
this.renderSubagentPanel();
// Update floating window (debounced — tool_call events fire rapidly)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
}
_onSubagentProgress(data) {
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'progress', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
// Update floating window (debounced)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
}
_onSubagentMessage(data) {
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'message', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
// Update floating window (debounced)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
}
_onSubagentToolResult(data) {
// Store tool result by toolUseId for later lookup (cap at 50 per agent)
if (!this.subagentToolResults.has(data.agentId)) {
this.subagentToolResults.set(data.agentId, new Map());
}
const resultsMap = this.subagentToolResults.get(data.agentId);
resultsMap.set(data.toolUseId, data);
if (resultsMap.size > 50) {
const oldest = resultsMap.keys().next().value;
resultsMap.delete(oldest);
}
// Add to activity stream
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'tool_result', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
// Update floating window (debounced)
if (this.subagentWindows.has(data.agentId)) {
this.scheduleSubagentWindowRender(data.agentId);
}
}
async _onSubagentCompleted(data) {
const existing = this.subagents.get(data.agentId);
if (existing) {
existing.status = 'completed';
this.subagents.set(data.agentId, existing);
}
this.renderSubagentPanel();
this.updateSubagentWindows();
// Auto-minimize completed subagent windows
if (this.subagentWindows.has(data.agentId)) {
const windowData = this.subagentWindows.get(data.agentId);
if (windowData && !windowData.minimized) {
await this.closeSubagentWindow(data.agentId); // This minimizes to tab
this.saveSubagentWindowStates(); // Persist the minimized state
}
}
// Notify about subagent completion
const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null;
this.notificationManager?.notify({
urgency: 'info',
category: 'subagent-complete',
sessionId: parentId || existing?.sessionId || data.sessionId,
sessionName: parentSession?.name || parentId || data.sessionId,
title: 'Subagent Completed',
message: existing?.description || data.description || 'Background agent finished',
});
// Clean up activity/tool data for completed agents after 5 minutes
// This prevents memory leaks from long-running sessions with many subagents
setTimeout(() => {
const agent = this.subagents.get(data.agentId);
// Only clean up if agent is still completed (not restarted)
if (agent?.status === 'completed') {
this.subagentActivity.delete(data.agentId);
this.subagentToolResults.delete(data.agentId);
}
}, 5 * 60 * 1000); // 5 minutes
// Prune stale completed agents from main maps after 30 minutes
// Keeps subagents/subagentParentMap from growing unbounded in 24h sessions
setTimeout(() => {
const agent = this.subagents.get(data.agentId);
if (agent?.status === 'completed' && !this.subagentWindows.has(data.agentId)) {
this.subagents.delete(data.agentId);
this.subagentParentMap.delete(data.agentId);
}
}, 30 * 60 * 1000); // 30 minutes
}
// Images
_onImageDetected(data) {
console.log('[Image Detected]', data);
this.openImagePopup(data);
}
// Tunnel
_onTunnelStarted(data) {
console.log('[Tunnel] Started:', data.url);
this._tunnelUrl = data.url;
this._dismissTunnelConnecting();
this._updateTunnelUrlDisplay(data.url);
this._updateTunnelIndicator(true);
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
if (welcomeVisible) {
// On welcome screen: QR appears inline, expanded first
this._updateWelcomeTunnelBtn(true, data.url, true);
this.showToast(`Tunnel active`, 'success');
} else {
// Not on welcome screen: popup QR overlay
this._updateWelcomeTunnelBtn(true, data.url);
this.showToast(`Tunnel active: ${data.url}`, 'success');
this.showTunnelQR();
}
}
_onTunnelStopped() {
console.log('[Tunnel] Stopped');
this._tunnelUrl = null;
this._dismissTunnelConnecting();
this._updateTunnelUrlDisplay(null);
this._updateWelcomeTunnelBtn(false);
this._updateTunnelIndicator(false);
this.closeTunnelPanel();
this.closeTunnelQR();
}
_onTunnelProgress(data) {
console.log('[Tunnel] Progress:', data.message);
const toast = document.getElementById('tunnelConnectingToast');
if (toast) {
toast.innerHTML = `<span class="tunnel-spinner"></span> ${data.message}`;
}
// Also update button text if on welcome screen
const btn = document.getElementById('welcomeTunnelBtn');
if (btn?.classList.contains('connecting')) {
btn.innerHTML = `<span class="tunnel-spinner"></span> ${data.message}`;
}
}
_onTunnelError(data) {
console.warn('[Tunnel] Error:', data.message);
this._dismissTunnelConnecting();
this.showToast(`Tunnel error: ${data.message}`, 'error');
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) { btn.disabled = false; btn.classList.remove('connecting'); }
}
_onTunnelQrRotated(data) {
if (data.svg) {
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = data.svg;
const welcomeInner = document.getElementById('welcomeQrInner');
if (welcomeInner) welcomeInner.innerHTML = data.svg;
} else {
this._refreshTunnelQrFromApi();
}
this._resetQrCountdown();
}
_onTunnelQrRegenerated(data) {
if (data.svg) {
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = data.svg;
const welcomeInner = document.getElementById('welcomeQrInner');
if (welcomeInner) welcomeInner.innerHTML = data.svg;
} else {
this._refreshTunnelQrFromApi();
}
this._resetQrCountdown();
}
_onTunnelQrAuthUsed(data) {
const ua = data.ua || 'Unknown device';
const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser';
this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', {
duration: 10000,
action: { label: 'Revoke All', onClick: () => {
fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(() => this.showToast('All sessions revoked', 'success'))
.catch(() => this.showToast('Failed to revoke sessions', 'error'));
}},
});
}
// Plan orchestration
_onPlanSubagent(data) {
console.log('[Plan Subagent]', data);
this.handlePlanSubagentEvent(data);
}
_onPlanProgress(data) {
console.log('[Plan Progress]', data);
// Update UI if we have a progress handler registered
if (this._planProgressHandler) {
this._planProgressHandler({ type: 'plan:progress', data });
}
// Also update the loading display directly for better feedback
const titleEl = document.getElementById('planLoadingTitle');
const hintEl = document.getElementById('planLoadingHint');
if (titleEl && data.phase) {
const phaseLabels = {
'parallel-analysis': 'Running parallel analysis...',
'subagent': data.detail || 'Subagent working...',
'synthesis': 'Synthesizing results...',
'verification': 'Running verification...',
};
titleEl.textContent = phaseLabels[data.phase] || data.phase;
}
if (hintEl && data.detail) {
hintEl.textContent = data.detail;
}
}
_onPlanStarted(data) {
console.log('[Plan Started]', data);
this.activePlanOrchestratorId = data.orchestratorId;
this.planGenerationStopped = false; // Reset flag for new generation
this.renderMonitorPlanAgents();
}
_onPlanCancelled(data) {
console.log('[Plan Cancelled]', data);
if (this.activePlanOrchestratorId === data.orchestratorId) {
this.activePlanOrchestratorId = null;
}
this.renderMonitorPlanAgents();
}
_onPlanCompleted(data) {
console.log('[Plan Completed]', data);
if (this.activePlanOrchestratorId === data.orchestratorId) {
this.activePlanOrchestratorId = null;
}
this.renderMonitorPlanAgents();
}
// ═══════════════════════════════════════════════════════════════
// Connection Status, Input Queuing & State Initialization
// ═══════════════════════════════════════════════════════════════
setConnectionStatus(status) {
this._connectionStatus = status;
this._updateConnectionIndicator();
if (status === 'connected' && this._inputQueue.size > 0) {
this._drainInputQueues();
}
}
/**
* Send input to server without blocking the keystroke flush cycle.
* Uses a sequential promise chain to preserve character ordering
* across concurrent async fetches.
*/
_sendInputAsync(sessionId, input) {
// Queue immediately if offline
if (!this.isOnline || this._connectionStatus === 'disconnected') {
this._enqueueInput(sessionId, input);
return;
}
// Chain on dispatch only — wait for the previous request to be sent before
// dispatching the next one (preserves keystroke ordering), but don't wait
// for the server's response. The server handles writeViaMux as
// fire-and-forget anyway, so the HTTP response carries no useful data
// beyond success/failure for retry purposes.
this._inputSendChain = this._inputSendChain.then(() => {
const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
keepalive: input.length < 65536,
});
// Handle response asynchronously — don't block next keystroke on response
fetchPromise.then(resp => {
if (!resp.ok) {
this._enqueueInput(sessionId, input);
} else {
this.clearPendingHooks(sessionId);
}
}).catch(() => {
this._enqueueInput(sessionId, input);
});
// Return immediately after fetch is dispatched (don't await response)
});
}
_enqueueInput(sessionId, input) {
const existing = this._inputQueue.get(sessionId) || '';
let combined = existing + input;
// Enforce 64KB cap — keep most recent keystrokes
if (combined.length > this._inputQueueMaxBytes) {
combined = combined.slice(combined.length - this._inputQueueMaxBytes);
}
this._inputQueue.set(sessionId, combined);
this._updateConnectionIndicator();
}
async _drainInputQueues() {
if (this._inputQueue.size === 0) return;
// Snapshot and clear
const queued = new Map(this._inputQueue);
this._inputQueue.clear();
this._updateConnectionIndicator();
for (const [sessionId, input] of queued) {
const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input });
if (!resp?.ok) {
this._enqueueInput(sessionId, input);
}
}
this._updateConnectionIndicator();
}
_updateConnectionIndicator() {
const indicator = this.$('connectionIndicator');
const dot = this.$('connectionDot');
const text = this.$('connectionText');
if (!indicator || !dot || !text) return;
let totalBytes = 0;
for (const v of this._inputQueue.values()) totalBytes += v.length;
const status = this._connectionStatus;
const hasQueue = totalBytes > 0;
// Connected with empty queue — hide
if ((status === 'connected' || status === 'connecting') && !hasQueue) {
indicator.style.display = 'none';
return;
}
indicator.style.display = 'flex';
dot.className = 'connection-dot';
const formatBytes = (b) => b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`;
if (status === 'connected' && hasQueue) {
// Draining
dot.classList.add('draining');
text.textContent = `Sending ${formatBytes(totalBytes)}...`;
} else if (status === 'reconnecting') {
dot.classList.add('reconnecting');
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
} else {
// Offline or disconnected
dot.classList.add('offline');
text.textContent = hasQueue ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline';
}
}
setupOnlineDetection() {
window.addEventListener('online', () => {
this.isOnline = true;
this.reconnectAttempts = 0;
this.connectSSE();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.setConnectionStatus('offline');
});
}
handleInit(data) {
// Clear the init fallback timer since we got data
if (this._initFallbackTimer) {
clearTimeout(this._initFallbackTimer);
this._initFallbackTimer = null;
}
const gen = ++this._initGeneration;
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
const headerVersionEl = this.$('headerVersion');
if (versionEl) {
versionEl.textContent = `v${data.version}`;
versionEl.title = `Codeman v${data.version}`;
}
if (headerVersionEl) {
headerVersionEl.textContent = `v${data.version}`;
headerVersionEl.title = `Codeman v${data.version}`;
}
}
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
this.sessions.clear();
this.ralphStates.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.projectInsights.clear();
this.teams.clear();
this.teamTasks.clear();
// Clear all idle timers to prevent stale timers from firing
for (const timer of this.idleTimers.values()) {
clearTimeout(timer);
}
this.idleTimers.clear();
// Clear flicker filter state
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
// Clear pending terminal writes
if (this.syncWaitTimeout) {
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
// Preserve local echo overlay text across SSE reconnect — just hide until
// terminal buffer reloads and prompt is visible again. _render() re-scans
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
this._localEchoOverlay?.rerender();
// Clear pending hooks
this.pendingHooks.clear();
// Clear parent name cache (prevents stale session name entries accumulating)
if (this._parentNameCache) this._parentNameCache.clear();
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
this.subagentActivity.clear();
this.subagentToolResults.clear();
// Clean up mobile/keyboard handlers and re-init (prevents listener accumulation on reconnect)
MobileDetection.cleanup();
KeyboardHandler.cleanup();
MobileDetection.init();
KeyboardHandler.init();
// 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 notification manager grouping timeouts (prevents orphaned timers)
if (this.notificationManager?.groupingMap) {
for (const { timeout } of this.notificationManager.groupingMap.values()) {
clearTimeout(timeout);
}
this.notificationManager.groupingMap.clear();
}
// Disconnect terminal resize observer (prevents memory leak on reconnect)
if (this.terminalResizeObserver) {
this.terminalResizeObserver.disconnect();
this.terminalResizeObserver = 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 (only if not explicitly closed by user)
if ((s.ralphLoop || s.ralphTodos) && !this.ralphClosedSessions.has(s.id)) {
this.ralphStates.set(s.id, {
loop: s.ralphLoop || null,
todos: s.ralphTodos || []
});
}
});
// Restore tabs that were open before refresh but are no longer on the server
this._restoreEndedTabs();
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
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();
// Start/stop system stats polling based on session count
if (this.sessions.size > 0) {
this.startSystemStatsPolling();
} else {
this.stopSystemStatsPolling();
}
// 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();
// Load PERSISTENT parent associations FIRST, before restoring windows
// This ensures connection lines are drawn to the correct tabs
// Clear the in-memory map first to ensure fresh state from storage
this.subagentParentMap.clear();
this.loadSubagentParentMap().then(() => {
// Apply stored parent associations to agents
for (const [agentId, sessionId] of this.subagentParentMap) {
const agent = this.subagents.get(agentId);
if (agent && this.sessions.has(sessionId)) {
agent.parentSessionId = sessionId;
const session = this.sessions.get(sessionId);
if (session) {
agent.parentSessionName = this.getSessionName(session);
}
this.subagents.set(agentId, agent);
}
}
// Now try to find parents for any agents that don't have one yet
for (const [agentId] of this.subagents) {
if (!this.subagentParentMap.has(agentId)) {
this.findParentSessionForSubagent(agentId);
}
}
// Finally, restore window states (this opens windows with correct parent info)
this.restoreSubagentWindowStates();
});
}
// Restore previously active session (survives page reload + SSE reconnect)
// Must always re-select because handleInit clears terminal state above.
// Reset activeSessionId so selectSession doesn't early-return.
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
if (gen !== this._initGeneration) return;
const previousActiveId = this.activeSessionId;
this.activeSessionId = null;
if (this.sessionOrder.length > 0) {
// Priority: current active > localStorage > first session
let restoreId = previousActiveId;
if (!restoreId || !this.sessions.has(restoreId)) {
try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
}
if (restoreId && this.sessions.has(restoreId)) {
this.selectSession(restoreId);
} else {
this.selectSession(this.sessionOrder[0]);
}
}
}
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);
}
/** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */
_updateActiveTabImmediate(sessionId) {
const container = this.$('sessionTabs');
if (!container) return;
const tabs = container.querySelectorAll('.session-tab[data-id]');
for (const tab of tabs) {
if (tab.dataset.id === sessionId) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
}
}
_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 - targeted update without full rebuild
const subagentBadgeEl = tab.querySelector('.tab-subagent-badge');
const minimizedAgents = this.minimizedSubagents.get(id);
const minimizedCount = minimizedAgents?.size || 0;
if (minimizedCount > 0 && subagentBadgeEl) {
// Badge exists and still has agents - update label and dropdown in-place
const labelEl = subagentBadgeEl.querySelector('.subagent-label');
const newLabel = minimizedCount === 1 ? 'AGENT' : `AGENTS (${minimizedCount})`;
if (labelEl && labelEl.textContent !== newLabel) {
labelEl.textContent = newLabel;
}
// Rebuild dropdown items (agent list may have changed)
const dropdownEl = subagentBadgeEl.querySelector('.subagent-dropdown');
if (dropdownEl) {
const newBadgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
const temp = document.createElement('div');
temp.innerHTML = newBadgeHtml;
const newDropdown = temp.querySelector('.subagent-dropdown');
if (newDropdown) {
dropdownEl.innerHTML = newDropdown.innerHTML;
}
}
} else if (minimizedCount > 0 && !subagentBadgeEl) {
// Need to add badge - insert before gear icon
const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
const gearEl = tab.querySelector('.tab-gear');
if (gearEl) {
gearEl.insertAdjacentHTML('beforebegin', badgeHtml);
}
} else if (minimizedCount === 0 && subagentBadgeEl) {
// Count went to 0 - remove badge
subagentBadgeEl.remove();
}
}
} else {
// Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs();
}
}
_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
// Iterate in sessionOrder to respect user's custom tab arrangement
// On mobile: put active session first (only one tab visible anyway)
const parts = [];
let tabOrder = this.sessionOrder;
if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) {
// Reorder to put active tab first
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
}
for (const id of tabOrder) {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const isActive = id === this.activeSessionId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
const mode = session.mode || 'claude';
const color = session.color || 'default';
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) : '';
// Show folder name if session has a custom name AND tall tabs setting is enabled
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
const endedAttr = session._ended ? ' data-ended="1"' : '';
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
<span class="tab-name" data-session-id="${id}">${escapeHtml(name)}</span>
</span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`);
}
container.innerHTML = parts.join('');
// Persist tab metadata for refresh recovery
this._saveTabMetadata();
// Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers();
// Set up keyboard navigation for tabs
this.setupTabKeyboardNavigation(container);
// Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines();
}
// Set up arrow key navigation for session tabs (accessibility)
setupTabKeyboardNavigation(container) {
// Remove existing listener if any to avoid duplicates
if (this._tabKeydownHandler) {
container.removeEventListener('keydown', this._tabKeydownHandler);
}
this._tabKeydownHandler = (e) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
const tabs = [...container.querySelectorAll('.session-tab')];
const currentIndex = tabs.indexOf(document.activeElement);
// Enter or Space activates the tab
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
e.preventDefault();
const sessionId = tabs[currentIndex].dataset.id;
this.selectSession(sessionId);
return;
}
if (currentIndex < 0) return;
let newIndex;
switch (e.key) {
case 'ArrowLeft':
newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
break;
case 'ArrowRight':
newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
tabs[newIndex]?.focus();
};
container.addEventListener('keydown', this._tabKeydownHandler);
}
// ═══════════════════════════════════════════════════════════════
// Tab Order and Drag-and-Drop
// ═══════════════════════════════════════════════════════════════
// Sync sessionOrder with current sessions (preserve order for existing, add new at end)
syncSessionOrder() {
const currentIds = new Set(this.sessions.keys());
// Load saved order from localStorage
const savedOrder = this.loadSessionOrder();
// Start with saved order, keeping only sessions that still exist
const preserved = savedOrder.filter(id => currentIds.has(id));
const preservedSet = new Set(preserved);
// Add any new sessions at the end
const newSessions = [...currentIds].filter(id => !preservedSet.has(id));
this.sessionOrder = [...preserved, ...newSessions];
}
// Load session order from localStorage
loadSessionOrder() {
try {
const saved = localStorage.getItem('codeman-session-order');
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
// Save session order to localStorage
saveSessionOrder() {
try {
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
} catch {
// Ignore storage errors
}
}
// Save tab metadata to localStorage so ended sessions can be restored after refresh
_saveTabMetadata() {
try {
const meta = {};
for (const [id, s] of this.sessions) {
if (s._ended) continue; // Don't persist ended stubs back
meta[id] = { id, name: s.name || '', workingDir: s.workingDir || '', mode: s.mode || 'claude', color: s.color || 'default' };
}
localStorage.setItem('codeman-tab-meta', JSON.stringify(meta));
} catch { /* ignore */ }
}
// Restore tabs that were open before refresh but are no longer on the server
_restoreEndedTabs() {
try {
const saved = localStorage.getItem('codeman-tab-meta');
if (!saved) return;
const meta = JSON.parse(saved);
for (const [id, info] of Object.entries(meta)) {
if (!this.sessions.has(id)) {
// Add a stub session so the tab renders
this.sessions.set(id, { id, name: info.name, workingDir: info.workingDir, mode: info.mode, color: info.color, status: 'ended', _ended: true });
}
}
} catch { /* ignore */ }
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
const tabs = container.querySelectorAll('.session-tab[data-id]');
tabs.forEach(tab => {
tab.setAttribute('draggable', 'true');
tab.addEventListener('dragstart', (e) => {
this.draggedTabId = tab.dataset.id;
tab.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', tab.dataset.id);
});
tab.addEventListener('dragend', () => {
tab.classList.remove('dragging');
this.draggedTabId = null;
// Remove all drag-over indicators
container.querySelectorAll('.session-tab').forEach(t => {
t.classList.remove('drag-over-left', 'drag-over-right');
});
});
tab.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
e.dataTransfer.dropEffect = 'move';
// Determine drop position based on mouse position
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const isLeftHalf = e.clientX < midpoint;
// Update visual indicator
tab.classList.toggle('drag-over-left', isLeftHalf);
tab.classList.toggle('drag-over-right', !isLeftHalf);
});
tab.addEventListener('dragleave', () => {
tab.classList.remove('drag-over-left', 'drag-over-right');
});
tab.addEventListener('drop', (e) => {
e.preventDefault();
tab.classList.remove('drag-over-left', 'drag-over-right');
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
// Determine insertion position
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const insertBefore = e.clientX < midpoint;
// Reorder sessionOrder array
const fromIndex = this.sessionOrder.indexOf(draggedId);
let toIndex = this.sessionOrder.indexOf(targetId);
if (fromIndex === -1 || toIndex === -1) return;
// Remove dragged item
this.sessionOrder.splice(fromIndex, 1);
// Recalculate target index after removal
toIndex = this.sessionOrder.indexOf(targetId);
if (toIndex === -1) return;
// Insert at correct position
if (insertBefore) {
this.sessionOrder.splice(toIndex, 0, draggedId);
} else {
this.sessionOrder.splice(toIndex + 1, 0, draggedId);
}
// Save and re-render
this.saveSessionOrder();
this._fullRenderSessionTabs();
});
});
}
// ═══════════════════════════════════════════════════════════════
// Session Lifecycle — select, close, navigate
// ═══════════════════════════════════════════════════════════════
getShortId(id) {
if (!id) return '';
let short = this._shortIdCache.get(id);
if (!short) {
short = id.slice(0, 8);
this._shortIdCache.set(id, short);
}
return short;
}
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 this.getShortId(session.id);
}
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`);
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
const selectGen = ++this._selectGeneration;
if (selectGen !== this._selectGeneration) return; // newer tab switch won
// Clean up flicker filter state when switching sessions
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
// Clear tab completion detection flag — don't carry across sessions
this._tabCompletionSessionId = null;
this._tabCompletionRetries = 0;
this._tabCompletionBaseText = null;
if (this._tabCompletionFallback) { clearTimeout(this._tabCompletionFallback); this._tabCompletionFallback = null; }
if (this._clientDropRecoveryTimer) { clearTimeout(this._clientDropRecoveryTimer); this._clientDropRecoveryTimer = null; }
// Clean up pending terminal writes to prevent old session data from appearing in new session
if (this.syncWaitTimeout) {
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
// End any in-flight IME composition.
// iOS Safari keeps autocorrect composing; switching tabs without ending it
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
// keyboard input when the user returns to this tab.
try {
const ch = this.terminal?._core?._compositionHelper;
if (ch?._isComposing) {
ch._isComposing = false;
// Also fire compositionend on the textarea so any other listeners reset
const ta = this.terminal?.element?.querySelector('.xterm-helper-textarea');
if (ta) ta.dispatchEvent(new CompositionEvent('compositionend', { data: '' }));
}
} catch {}
// Flush local echo text to PTY before switching tabs.
// Send as a single batch (no Enter) so it lands in the session's readline
// input buffer — avoids "old text resent on Enter" and overlay render bugs.
// Track flushed length so _render() offsets the overlay correctly even before
// the PTY echo arrives in the terminal buffer.
if (this.activeSessionId) {
const echoText = this._localEchoOverlay?.pendingText || '';
// Include buffer-detected flushed text (from Tab completion, etc.)
// so it's preserved across tab switches.
const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
if (echoText) {
this._sendInputAsync(this.activeSessionId, echoText);
}
const totalOffset = existingFlushed + echoText.length;
if (totalOffset > 0) {
if (!this._flushedOffsets) this._flushedOffsets = new Map();
if (!this._flushedTexts) this._flushedTexts = new Map();
this._flushedOffsets.set(this.activeSessionId, totalOffset);
this._flushedTexts.set(this.activeSessionId, existingFlushedText + echoText);
}
}
this._localEchoOverlay?.clear();
// Prevent _detectBufferText() from picking up Claude's Ink UI text
// (status bar, model info, etc.) as "user input" on fresh sessions.
// Only sessions with prior flushed text (from tab-switch-away) need detection.
// After the user's first Enter, clear() resets _bufferDetectDone = false,
// re-enabling detection for tab completion and other legitimate cases.
if (this._localEchoOverlay && !this._flushedOffsets?.has(sessionId)) {
this._localEchoOverlay.suppressBufferDetection();
}
this.activeSessionId = sessionId;
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
this.hideWelcome();
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
this.renderSessionTabs();
this._updateLocalEchoState();
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
// the async buffer load. Without this, the offset is 0 during the
// fetch() gap: backspace is swallowed, and typing a space covers the
// canvas text with an opaque overlay showing only the new char.
if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
this._localEchoOverlay.setFlushed(
this._flushedOffsets.get(sessionId),
this._flushedTexts?.get(sessionId) || '',
false // render=false: buffer not loaded yet
);
}
// Glow the newly-active tab
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
if (activeTab) {
activeTab.classList.add('tab-glow');
activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
}
// Check if this is a restored session that needs to be attached
const session = this.sessions.get(sessionId);
// Ended tabs (restored from localStorage, no longer on server) — show message, skip buffer load
if (session?._ended) {
this.terminal.clear();
this.terminal.write('\r\n \x1b[2mSession ended. Close tab or click to reopen.\x1b[0m\r\n');
return;
}
// 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/shell
try {
const endpoint = session.mode === 'shell'
? `/api/sessions/${sessionId}/shell`
: `/api/sessions/${sessionId}/interactive`;
await fetch(endpoint, { 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
// Show cached content instantly while fetching fresh data in background.
// Use tail mode for faster initial load (128KB is enough for recent visible content).
//
// Protect flushed state during buffer load: terminal.write() can trigger
// xterm.js onData responses (DA, OSC, etc.) that would otherwise clear
// the flushed Maps via the control char handler. The multi-byte ESC
// filter catches most cases, but _restoringFlushedState provides a
// belt-and-suspenders guard for any edge cases.
this._restoringFlushedState = true;
// Gate live SSE terminal writes for the ENTIRE buffer load sequence.
// Without this, SSE events arriving during the fetch() gap compete with
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
try {
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
if (cachedBuffer) {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(cachedBuffer);
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE');
}
_crashDiag.log('FETCH_START');
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
const data = await res.json();
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
if (data.terminalBuffer) {
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
// very visible, causing the terminal to flash blank then repaint.
const needsRewrite = data.terminalBuffer !== cachedBuffer;
if (needsRewrite) {
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
this.terminal.clear();
this.terminal.reset();
// 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);
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();
}
// Update cache (cap at 20 entries)
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
if (this.terminalBufferCache.size > 20) {
// Evict oldest entry (first key in Map iteration order)
const oldest = this.terminalBufferCache.keys().next().value;
this.terminalBufferCache.delete(oldest);
}
} else if (!cachedBuffer) {
// No fresh buffer and no cache — clear any stale content
this.terminal.clear();
this.terminal.reset();
}
// Buffer load complete — unblock live SSE writes and flush any queued events.
// chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped
// the chunked write (small buffer, cache hit, or empty), we must call it here.
if (this._isLoadingBuffer) {
this._finishBufferLoad();
}
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
// Restore flushed offset and text for this session so the overlay positions
// correctly even before the PTY echo arrives in the terminal buffer.
if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
this._localEchoOverlay.setFlushed(
this._flushedOffsets.get(sessionId),
this._flushedTexts?.get(sessionId) || '',
false // render=false: buffer just loaded, defer to rerender
);
// Trigger render after xterm.js finishes processing the buffer data.
// terminal.write('', callback) fires the callback after ALL previously
// queued writes have been parsed — so findPrompt() can find ❯ in the buffer.
const zl = this._localEchoOverlay;
this.terminal.write('', () => {
if (zl.hasPending) zl.rerender();
});
}
// Fire-and-forget resize — don't await to avoid blocking UI.
// The resize triggers an Ink redraw in Claude which streams back via SSE.
this.sendResize(sessionId);
// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
idleCb(() => {
// Guard against stale generation — user may have switched tabs again
if (selectGen !== this._selectGeneration) return;
// Update respawn banner
if (this.respawnStatus[sessionId]) {
this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
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 curSession = this.sessions.get(sessionId);
if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) {
this.updateRalphState(sessionId, {
loop: curSession.ralphLoop,
todos: curSession.ralphTodos
});
}
this.renderRalphStatePanel();
// Update CLI info bar (mobile - shows Claude version/model)
this.updateCliInfoDisplay();
// 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);
// Attach drag listeners if not already attached
if (!this.fileBrowserDragListeners) {
const header = fileBrowserPanel.querySelector('.file-browser-header');
if (header) {
const onFirstDrag = () => {
if (!fileBrowserPanel.style.left) {
const rect = fileBrowserPanel.getBoundingClientRect();
fileBrowserPanel.style.left = `${rect.left}px`;
fileBrowserPanel.style.top = `${rect.top}px`;
fileBrowserPanel.style.right = 'auto';
}
};
header.addEventListener('mousedown', onFirstDrag);
header.addEventListener('touchstart', onFirstDrag, { passive: true });
this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header);
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
}
}
});
_crashDiag.log('FOCUS');
this.terminal.focus();
this.terminal.scrollToBottom();
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad();
this._restoringFlushedState = false;
console.error('Failed to load session terminal:', err);
}
}
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
this.sessions.delete(sessionId);
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId);
if (orderIndex !== -1) {
this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
this._inputQueue.delete(sessionId);
this.ralphStates.delete(sessionId);
this.ralphClosedSessions.delete(sessionId);
this.projectInsights.delete(sessionId);
this.pendingHooks.delete(sessionId);
this.tabAlerts.delete(sessionId);
this.clearCountdownTimers(sessionId);
this.closeSessionLogViewerWindows(sessionId);
this.closeSessionImagePopups(sessionId);
this.closeSessionSubagentWindows(sessionId, true);
// Clean up idle timer
const idleTimer = this.idleTimers.get(sessionId);
if (idleTimer) {
clearTimeout(idleTimer);
this.idleTimers.delete(sessionId);
}
// Clean up respawn state
delete this.respawnStatus[sessionId];
delete this.respawnTimers[sessionId];
delete this.respawnCountdownTimers[sessionId];
delete this.respawnActionLogs[sessionId];
}
async closeSession(sessionId, killMux = true) {
try {
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
this._cleanupSessionData(sessionId);
if (this.activeSessionId === sessionId) {
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
// Select another session or show welcome (use sessionOrder for consistent ordering)
if (this.sessionOrder.length > 0 && this.sessions.size > 0) {
const nextSessionId = this.sessionOrder[0];
this.selectSession(nextSessionId);
} else {
this.terminal.clear();
this.showWelcome();
this.renderRalphStatePanel(); // Clear ralph panel when no sessions
}
}
this.renderSessionTabs();
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
this.showToast('Tab hidden, tmux 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;
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
killTitle.textContent = session.mode === 'opencode'
? 'Kill Tmux & OpenCode'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
}
cancelCloseSession() {
this.pendingCloseSessionId = null;
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killMux = true) {
const sessionId = this.pendingCloseSessionId;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
}
}
nextSession() {
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[nextIndex]);
}
prevSession() {
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const prevIndex = (currentIndex - 1 + this.sessionOrder.length) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[prevIndex]);
}
// ═══════════════════════════════════════════════════════════════
// Navigation
// ═══════════════════════════════════════════════════════════════
goHome() {
// Deselect active session and show welcome screen
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.terminal.clear();
this.showWelcome();
this.renderSessionTabs();
this.renderRalphStatePanel();
}
// ═══════════════════════════════════════════════════════════════
// Ralph Loop Wizard (methods in ralph-wizard.js)
// ═══════════════════════════════════════════════════════════════
// Wizard state (initialized here, methods loaded from ralph-wizard.js)
ralphWizardStep = 1;
ralphWizardConfig = {
taskDescription: '',
completionPhrase: 'COMPLETE',
maxIterations: 10,
caseName: 'testcase',
enableRespawn: false,
generatedPlan: null,
planGenerated: false,
skipPlanGeneration: false,
planDetailLevel: 'detailed',
existingPlan: null,
useExistingPlan: false,
};
planLoadingTimer = null;
planLoadingStartTime = null;
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
async loadQuickStartCases(selectCaseName = null, settingsPromise = null) {
try {
// Load settings to get lastUsedCase (reuse shared promise if provided)
let lastUsedCase = null;
try {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null);
if (settings) {
lastUsedCase = settings.lastUsedCase || null;
}
} catch {
// Ignore settings load errors
}
const res = await fetch('/api/cases');
const cases = await res.json();
this.cases = cases;
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
const select = document.getElementById('quickStartCase');
// Build options - existing cases first, then testcase as fallback if not present
let options = '';
const hasTestcase = cases.some(c => c.name === 'testcase');
const isMobile = MobileDetection.getDeviceType() === 'mobile';
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
cases.forEach(c => {
const displayName = c.name.length > maxNameLength
? c.name.substring(0, maxNameLength) + '…'
: c.name;
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
});
// Add testcase option if it doesn't exist (will be created on first run)
if (!hasTestcase) {
options = `<option value="testcase">testcase</option>` + options;
}
select.innerHTML = options;
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
// If a specific case was requested, select it
if (selectCaseName) {
select.value = selectCaseName;
this.updateDirDisplayForCase(selectCaseName);
this.updateMobileCaseLabel(selectCaseName);
} else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) {
// Use lastUsedCase if available and exists
select.value = lastUsedCase;
this.updateDirDisplayForCase(lastUsedCase);
this.updateMobileCaseLabel(lastUsedCase);
} else if (cases.length > 0) {
// Fallback to testcase or first case
const firstCase = cases.find(c => c.name === 'testcase') || cases[0];
select.value = firstCase.name;
this.updateDirDisplayForCase(firstCase.name);
this.updateMobileCaseLabel(firstCase.name);
} else {
// No cases exist yet - show the default case name as directory
select.value = 'testcase';
document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase';
this.updateMobileCaseLabel('testcase');
}
// Only add event listener once (on first load)
if (!select.dataset.listenerAdded) {
select.addEventListener('change', () => {
this.updateDirDisplayForCase(select.value);
this.saveLastUsedCase(select.value);
this.updateMobileCaseLabel(select.value);
});
select.dataset.listenerAdded = 'true';
}
} catch (err) {
console.error('Failed to load cases:', err);
}
}
async updateDirDisplayForCase(caseName) {
try {
const res = await fetch(`/api/cases/${caseName}`);
const data = await res.json();
if (data.path) {
document.getElementById('dirDisplay').textContent = data.path;
document.getElementById('dirInput').value = data.path;
}
} catch (err) {
document.getElementById('dirDisplay').textContent = caseName;
}
}
async saveLastUsedCase(caseName) {
try {
// Get current settings
const res = await fetch('/api/settings');
const settings = res.ok ? await res.json() : {};
// Update lastUsedCase
settings.lastUsedCase = caseName;
// Save back
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
} catch (err) {
console.error('Failed to save last used case:', err);
}
}
async quickStart() {
return this.run();
}
/** Run using the selected mode (Claude Code or OpenCode) */
async run() {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return this.runOpenCode();
}
return this.runClaude();
}
/** Get/set the run mode, persisted in localStorage */
get runMode() { return this._runMode || 'claude'; }
setRunMode(mode) {
this._runMode = mode;
try { localStorage.setItem('codeman_runMode', mode); } catch {}
this._applyRunMode();
// Sync to server for cross-device persistence
this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
// Close menu
document.getElementById('runModeMenu')?.classList.remove('active');
}
toggleRunModeMenu(e) {
e?.stopPropagation();
const menu = document.getElementById('runModeMenu');
if (!menu) return;
menu.classList.toggle('active');
// Update selected state
menu.querySelectorAll('.run-mode-option').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.mode === this.runMode);
});
// Load history sessions when menu opens
if (menu.classList.contains('active')) {
this._loadRunModeHistory();
const close = (ev) => {
if (!menu.contains(ev.target)) {
menu.classList.remove('active');
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
}
async _loadRunModeHistory() {
const container = document.getElementById('runModeHistory');
if (!container) return;
container.innerHTML = '<div class="run-mode-hist-empty">Loading...</div>';
try {
const res = await fetch('/api/history/sessions');
const data = await res.json();
const sessions = data.sessions || [];
if (sessions.length === 0) {
container.innerHTML = '<div class="run-mode-hist-empty">No history</div>';
return;
}
// Deduplicate: up to 2 per dir, max 10 total
const byDir = new Map();
for (const s of sessions) {
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
byDir.get(s.workingDir).push(s);
}
const items = [];
for (const [, group] of byDir) {
items.push(...group.slice(0, 2));
}
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
const display = items.slice(0, 10);
// Build items using DOM API for reliable mobile touch handling
container.replaceChildren();
for (const s of display) {
const date = new Date(s.lastModified);
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
const btn = document.createElement('button');
btn.className = 'run-mode-option';
btn.title = s.workingDir;
btn.dataset.sessionId = s.sessionId;
btn.dataset.workingDir = s.workingDir;
const dirSpan = document.createElement('span');
dirSpan.className = 'hist-dir';
dirSpan.textContent = shortDir;
const metaSpan = document.createElement('span');
metaSpan.className = 'hist-meta';
metaSpan.textContent = timeStr;
btn.append(dirSpan, metaSpan);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir);
});
container.appendChild(btn);
}
} catch (err) {
container.innerHTML = '<div class="run-mode-hist-empty">Failed to load</div>';
}
}
_applyRunMode() {
const mode = this.runMode;
const runBtn = document.getElementById('runBtn');
const gearBtn = runBtn?.nextElementSibling;
const label = document.getElementById('runBtnLabel');
if (runBtn) {
runBtn.className = `btn-toolbar btn-run mode-${mode}`;
}
if (gearBtn) {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : 'Run';
}
}
_initRunMode() {
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
this._applyRunMode();
}
// Tab count stepper functions
incrementTabCount() {
const input = document.getElementById('tabCount');
const current = parseInt(input.value) || 1;
input.value = Math.min(20, current + 1);
}
decrementTabCount() {
const input = document.getElementById('tabCount');
const current = parseInt(input.value) || 1;
input.value = Math.max(1, current - 1);
}
// Shell count stepper functions
incrementShellCount() {
const input = document.getElementById('shellCount');
const current = parseInt(input.value) || 1;
input.value = Math.min(20, current + 1);
}
decrementShellCount() {
const input = document.getElementById('shellCount');
const current = parseInt(input.value) || 1;
input.value = Math.max(1, current - 1);
}
async runClaude() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = await caseRes.json();
// Create the case if it doesn't exist
if (!caseData.path) {
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// Use the newly created case data (API returns { success, case: { name, path } })
caseData = createCaseData.case;
}
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
startNumber = num + 1;
}
}
}
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
// Create all sessions in parallel for speed
const sessionNames = [];
for (let i = 0; i < tabCount; i++) {
sessionNames.push(`w${startNumber + i}-${caseName}`);
}
// Build env overrides from global + case settings (case overrides global)
const caseSettings = this.getCaseSettings(caseName);
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = {};
if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) {
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
}
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
// Step 1: Create all sessions in parallel
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
const createPromises = sessionNames.map(name =>
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
// Collect created session IDs
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
sessionIds.push(result.session.id);
}
firstSessionId = sessionIds[0];
// Step 2: Configure Ralph for all sessions in parallel
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled })
})
));
// Step 3: Start all sessions in parallel (biggest speedup)
this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`);
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
));
this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`);
// Auto-switch to the new session using selectSession (does proper refresh)
if (firstSessionId) {
await this.selectSession(firstSessionId);
this.loadQuickStartCases();
}
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
}
/** Send Ctrl+C to the active session to stop the current operation.
* Requires double-tap: first tap turns button amber, second tap within 2s sends Ctrl+C. */
stopClaude() {
if (!this.activeSessionId) return;
const btn = document.querySelector('.btn-toolbar.btn-stop');
if (!btn) return;
if (this._stopConfirmTimer) {
// Second tap — send Ctrl+C
clearTimeout(this._stopConfirmTimer);
this._stopConfirmTimer = null;
btn.innerHTML = btn.dataset.origHtml;
delete btn.dataset.origHtml;
btn.classList.remove('confirming');
fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x03' })
});
} else {
// First tap — enter confirm state
btn.dataset.origHtml = btn.innerHTML;
btn.textContent = 'Tap again';
btn.classList.add('confirming');
this._stopConfirmTimer = setTimeout(() => {
this._stopConfirmTimer = null;
if (btn.dataset.origHtml) {
btn.innerHTML = btn.dataset.origHtml;
delete btn.dataset.origHtml;
}
btn.classList.remove('confirming');
}, 2000);
}
}
async runShell() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
this.terminal.clear();
this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseData = await caseRes.json();
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
// Find the highest existing s-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
startNumber = num + 1;
}
}
}
// Create all shell sessions in parallel
const sessionNames = [];
for (let i = 0; i < shellCount; i++) {
sessionNames.push(`s${startNumber + i}-${caseName}`);
}
// Step 1: Create all sessions in parallel
const createPromises = sessionNames.map(name =>
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell', name })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
sessionIds.push(result.session.id);
}
// Step 2: Start all shells in parallel
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/shell`, { method: 'POST' })
));
// Step 3: Resize all in parallel (with minimum dimension enforcement)
const dims = this.getTerminalDimensions();
if (dims) {
await Promise.all(sessionIds.map(id =>
fetch(`/api/sessions/${id}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims)
})
));
}
// Switch to first session
if (sessionIds.length > 0) {
this.activeSessionId = sessionIds[0];
await this.selectSession(sessionIds[0]);
}
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
}
async runOpenCode() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
try {
// Check if OpenCode is available
const statusRes = await fetch('/api/opencode/status');
const status = await statusRes.json();
if (!status.available) {
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
return;
}
// Quick-start with opencode mode (auto-allow tools by default)
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'opencode',
openCodeConfig: { autoAllowTools: true },
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
if (data.sessionId) {
await this.selectSession(data.sessionId);
}
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
}
// ═══════════════════════════════════════════════════════════════
// Directory Input
// ═══════════════════════════════════════════════════════════════
toggleDirInput() {
const btn = document.querySelector('#dirDisplay').parentElement;
const input = document.getElementById('dirInput');
if (input.classList.contains('hidden')) {
input.classList.remove('hidden');
btn.style.display = 'none';
input.focus();
}
}
hideDirInput() {
const btn = document.querySelector('#dirDisplay').parentElement;
const input = document.getElementById('dirInput');
setTimeout(() => {
input.classList.add('hidden');
btn.style.display = '';
const value = input.value.trim();
document.getElementById('dirDisplay').textContent = value || 'No directory';
}, 100);
}
// ═══════════════════════════════════════════════════════════════
// Respawn Banner
// ═══════════════════════════════════════════════════════════════
showRespawnBanner() {
this.$('respawnBanner').style.display = 'flex';
// Also show timer if there's a timed respawn
if (this.activeSessionId && this.respawnTimers[this.activeSessionId]) {
this.showRespawnTimer();
}
// Show tokens if session has token data
const session = this.sessions.get(this.activeSessionId);
if (session && session.tokens) {
this.updateRespawnTokens(session.tokens);
}
}
hideRespawnBanner() {
this.$('respawnBanner').style.display = 'none';
this.hideRespawnTimer();
}
// Human-friendly state labels
getStateLabel(state) {
const labels = {
'stopped': 'Stopped',
'watching': 'Watching',
'confirming_idle': 'Confirming idle',
'ai_checking': 'AI checking',
'sending_update': 'Sending prompt',
'waiting_update': 'Running prompt',
'sending_clear': 'Clearing context',
'waiting_clear': 'Clearing...',
'sending_init': 'Initializing',
'waiting_init': 'Initializing...',
'monitoring_init': 'Waiting for work',
'sending_kickstart': 'Kickstarting',
'waiting_kickstart': 'Kickstarting...',
};
return labels[state] || state.replace(/_/g, ' ');
}
updateRespawnBanner(state) {
const stateEl = this.$('respawnState');
stateEl.textContent = this.getStateLabel(state);
// Clear blocked state when state changes (resumed from blocked)
stateEl.classList.remove('respawn-blocked');
}
updateDetectionDisplay(detection) {
if (!detection) return;
const statusEl = this.$('detectionStatus');
const waitingEl = this.$('detectionWaiting');
const confidenceEl = this.$('detectionConfidence');
const aiCheckEl = document.getElementById('detectionAiCheck');
const hookEl = document.getElementById('detectionHook');
// Hook-based detection indicator (highest priority signals)
if (hookEl) {
if (detection.stopHookReceived || detection.idlePromptReceived) {
const hookType = detection.idlePromptReceived ? 'idle' : 'stop';
hookEl.textContent = `🎯 ${hookType} hook`;
hookEl.className = 'detection-hook hook-active';
hookEl.style.display = '';
} else {
hookEl.style.display = 'none';
}
}
// Simplified status - only show when meaningful
if (detection.statusText && detection.statusText !== 'Watching...') {
statusEl.textContent = detection.statusText;
statusEl.style.display = '';
} else {
statusEl.style.display = 'none';
}
// Hide "waiting for" text - it's redundant with the state label
waitingEl.style.display = 'none';
// Show confidence only when confirming (>0%)
const confidence = detection.confidenceLevel || 0;
if (confidence > 0) {
confidenceEl.textContent = `${confidence}%`;
confidenceEl.style.display = '';
confidenceEl.className = 'detection-confidence';
// Hook signals give 100% confidence
if (detection.stopHookReceived || detection.idlePromptReceived) {
confidenceEl.classList.add('hook-confirmed');
} else if (confidence >= 60) {
confidenceEl.classList.add('high');
} else if (confidence >= 30) {
confidenceEl.classList.add('medium');
}
} else {
confidenceEl.style.display = 'none';
}
// AI check display - compact format
if (aiCheckEl && detection.aiCheck) {
const ai = detection.aiCheck;
let aiText = '';
let aiClass = 'detection-ai-check';
if (ai.status === 'checking') {
aiText = '🔍 AI checking...';
aiClass += ' ai-checking';
} else if (ai.status === 'cooldown' && ai.cooldownEndsAt) {
const remaining = Math.ceil((ai.cooldownEndsAt - Date.now()) / 1000);
if (remaining > 0) {
if (ai.lastVerdict === 'WORKING') {
aiText = `⏳ Working, retry ${remaining}s`;
aiClass += ' ai-working';
} else {
aiText = `✓ Idle, wait ${remaining}s`;
aiClass += ' ai-idle';
}
}
} else if (ai.status === 'disabled') {
aiText = '⚠ AI disabled';
aiClass += ' ai-disabled';
} else if (ai.lastVerdict && ai.lastCheckTime) {
const ago = Math.round((Date.now() - ai.lastCheckTime) / 1000);
if (ago < 120) {
aiText = ai.lastVerdict === 'IDLE'
? `✓ Idle (${ago}s)`
: `⏳ Working (${ago}s)`;
aiClass += ai.lastVerdict === 'IDLE' ? ' ai-idle' : ' ai-working';
}
}
aiCheckEl.textContent = aiText;
aiCheckEl.className = aiClass;
aiCheckEl.style.display = aiText ? '' : 'none';
} else if (aiCheckEl) {
aiCheckEl.style.display = 'none';
}
// Manage row2 visibility - hide if nothing visible
const row2 = this.$('respawnStatusRow2');
if (row2) {
const hasVisibleContent =
(hookEl && hookEl.style.display !== 'none') ||
(aiCheckEl && aiCheckEl.style.display !== 'none') ||
(statusEl && statusEl.style.display !== 'none') ||
(this.respawnCountdownTimers[this.activeSessionId] &&
Object.keys(this.respawnCountdownTimers[this.activeSessionId]).length > 0);
row2.style.display = hasVisibleContent ? '' : 'none';
}
}
showRespawnTimer() {
const timerEl = this.$('respawnTimer');
timerEl.style.display = '';
this.updateRespawnTimer();
// Update every second
if (this.respawnTimerInterval) clearInterval(this.respawnTimerInterval);
this.respawnTimerInterval = setInterval(() => this.updateRespawnTimer(), 1000);
}
hideRespawnTimer() {
this.$('respawnTimer').style.display = 'none';
if (this.respawnTimerInterval) {
clearInterval(this.respawnTimerInterval);
this.respawnTimerInterval = null;
}
}
updateRespawnTimer() {
if (!this.activeSessionId || !this.respawnTimers[this.activeSessionId]) {
this.hideRespawnTimer();
return;
}
const timer = this.respawnTimers[this.activeSessionId];
// Guard against invalid timer data
if (!timer.endAt || isNaN(timer.endAt)) {
this.hideRespawnTimer();
return;
}
const now = Date.now();
const remaining = Math.max(0, timer.endAt - now);
if (remaining <= 0) {
this.$('respawnTimer').textContent = 'Time up';
delete this.respawnTimers[this.activeSessionId];
this.hideRespawnTimer();
return;
}
this.$('respawnTimer').textContent = this.formatTime(remaining);
}
updateRespawnTokens(tokens) {
// Skip if tokens haven't changed (avoid unnecessary DOM writes)
const isObject = tokens && typeof tokens === 'object';
const total = isObject ? tokens.total : tokens;
if (total === this._lastRespawnTokenTotal) return;
this._lastRespawnTokenTotal = total;
const tokensEl = this.$('respawnTokens');
const input = isObject ? (tokens.input || 0) : Math.round(total * 0.6);
const output = isObject ? (tokens.output || 0) : Math.round(total * 0.4);
if (total > 0) {
tokensEl.style.display = '';
const tokenStr = this.formatTokens(total);
const settings = this.loadAppSettingsFromStorage();
const showCost = settings.showCost ?? false;
if (showCost) {
const estimatedCost = this.estimateCost(input, output);
tokensEl.textContent = `${tokenStr} tokens · $${estimatedCost.toFixed(2)}`;
} else {
tokensEl.textContent = `${tokenStr} tokens`;
}
} else {
tokensEl.style.display = 'none';
}
// Also update mobile CLI info bar (shows tokens on mobile)
this.updateCliInfoDisplay();
}
// Update CLI info display (tokens, version, model - shown on mobile)
updateCliInfoDisplay() {
const infoBar = this.$('cliInfoBar');
if (!infoBar) return;
const session = this.sessions.get(this.activeSessionId);
if (!session) {
infoBar.style.display = 'none';
return;
}
// Build display parts - tokens first (most important on mobile)
let parts = [];
// Add tokens if available
if (session.tokens) {
const total = typeof session.tokens === 'object' ? session.tokens.total : session.tokens;
if (total > 0) {
parts.push(`${this.formatTokens(total)} tokens`);
}
}
// Add model (condensed)
if (session.cliModel) {
// Shorten model names for mobile: "claude-sonnet-4-20250514" -> "Sonnet 4"
let model = session.cliModel;
if (model.includes('opus')) model = 'Opus';
else if (model.includes('sonnet')) model = 'Sonnet';
else if (model.includes('haiku')) model = 'Haiku';
parts.push(model);
}
// Add version (compact format)
if (session.cliVersion) {
// Show "v2.1.27" or "v2.1.27 ↑" if update available
let versionStr = `v${session.cliVersion}`;
if (session.cliLatestVersion && session.cliLatestVersion !== session.cliVersion) {
versionStr += ' ↑'; // Arrow indicates update available
}
parts.push(versionStr);
}
if (parts.length > 0) {
infoBar.textContent = parts.join(' · ');
infoBar.style.display = '';
} else {
infoBar.style.display = 'none';
}
}
// ═══════════════════════════════════════════════════════════════
// Countdown Timer Display Methods
// ═══════════════════════════════════════════════════════════════
addActionLogEntry(sessionId, action) {
// Only keep truly interesting events - no spam
// KEEP: command (inputs), hook events, AI verdicts, plan verdicts
// SKIP: timer, timer-cancel, state changes, routine detection, step confirmations
const interestingTypes = ['command', 'hook'];
// Always keep commands and hooks
if (interestingTypes.includes(action.type)) {
// ok, keep it
}
// AI check: only verdicts (IDLE, WORKING) and errors, not "Spawning"
else if (action.type === 'ai-check') {
if (action.detail.includes('Spawning')) return;
}
// Plan check: only verdicts, not "Spawning"
else if (action.type === 'plan-check') {
if (action.detail.includes('Spawning')) return;
}
// Transcript: keep completion/plan detection
else if (action.type === 'transcript') {
// keep it
}
// Skip everything else (timer, timer-cancel, state, detection, step)
else {
return;
}
if (!this.respawnActionLogs[sessionId]) {
this.respawnActionLogs[sessionId] = [];
}
this.respawnActionLogs[sessionId].unshift(action);
// Keep reasonable history
if (this.respawnActionLogs[sessionId].length > 30) {
this.respawnActionLogs[sessionId].pop();
}
}
startCountdownInterval() {
if (this.timerCountdownInterval) return;
this.timerCountdownInterval = setInterval(() => {
if (this.activeSessionId && this.respawnCountdownTimers[this.activeSessionId]) {
this.updateCountdownTimerDisplay();
}
}, 100);
}
stopCountdownInterval() {
if (this.timerCountdownInterval) {
clearInterval(this.timerCountdownInterval);
this.timerCountdownInterval = null;
}
}
updateCountdownTimerDisplay() {
const timersContainer = this.$('respawnCountdownTimers');
const row2 = this.$('respawnStatusRow2');
if (!timersContainer) return;
const timers = this.respawnCountdownTimers[this.activeSessionId];
const hasTimers = timers && Object.keys(timers).length > 0;
if (!hasTimers) {
timersContainer.innerHTML = '';
// Update row2 visibility
if (row2) {
const hookEl = document.getElementById('detectionHook');
const aiCheckEl = document.getElementById('detectionAiCheck');
const statusEl = this.$('detectionStatus');
const hasVisibleContent =
(hookEl && hookEl.style.display !== 'none') ||
(aiCheckEl && aiCheckEl.style.display !== 'none') ||
(statusEl && statusEl.style.display !== 'none');
row2.style.display = hasVisibleContent ? '' : 'none';
}
return;
}
// Show row2 since we have timers
if (row2) row2.style.display = '';
const now = Date.now();
let html = '';
for (const [name, timer] of Object.entries(timers)) {
const remainingMs = Math.max(0, timer.endsAt - now);
const remainingSec = (remainingMs / 1000).toFixed(1);
const percent = Math.max(0, Math.min(100, (remainingMs / timer.totalMs) * 100));
// Shorter timer name display
const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
html += `<div class="respawn-countdown-timer" title="${escapeHtml(timer.reason || '')}">
<span class="timer-name">${escapeHtml(displayName)}</span>
<span class="timer-value">${remainingSec}s</span>
<div class="respawn-timer-bar">
<div class="respawn-timer-progress" style="width: ${percent}%"></div>
</div>
</div>`;
}
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 += `<div class="respawn-action-entry${extraClass}">
<span class="action-time">${time}</span>
<span class="action-type">[${action.type}]</span>
<span class="action-detail">${escapeHtml(action.detail)}</span>
</div>`;
}
logContainer.innerHTML = html;
}
clearCountdownTimers(sessionId) {
delete this.respawnCountdownTimers[sessionId];
delete this.respawnActionLogs[sessionId];
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
}
}
async stopRespawn() {
if (!this.activeSessionId) return;
try {
await this._apiPost(`/api/sessions/${this.activeSessionId}/respawn/stop`, {});
delete this.respawnTimers[this.activeSessionId];
this.clearCountdownTimers(this.activeSessionId);
} catch (err) {
this.showToast('Failed to stop respawn', 'error');
}
}
// ═══════════════════════════════════════════════════════════════
// Kill Sessions
// ═══════════════════════════════════════════════════════════════
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 this._apiDelete('/api/sessions');
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
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();
}
/**
* Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal.
* Sends resize to PTY and Ctrl+L to trigger Claude to redraw.
*/
async restoreTerminalSize() {
if (!this.activeSessionId) {
this.showToast('No active session', 'warning');
return;
}
const dims = this.getTerminalDimensions();
if (!dims) {
this.showToast('Could not determine terminal size', 'error');
return;
}
try {
// Send resize to restore proper dimensions (with minimum enforcement)
await this.sendResize(this.activeSessionId);
// Send Ctrl+L to trigger Claude to redraw at new size
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' })
});
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) {
console.error('Failed to restore terminal size:', err);
this.showToast('Failed to restore terminal size', 'error');
}
}
// Send Ctrl+L to fix display for newly created sessions once Claude is running
sendPendingCtrlL(sessionId) {
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
return;
}
this.pendingCtrlL.delete(sessionId);
// Only send if this is the active session
if (sessionId !== this.activeSessionId) {
return;
}
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
this.sendResize(sessionId).then(() => {
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '\x0c' })
});
});
}
async copyTerminal() {
try {
const buffer = this.terminal.buffer.active;
let text = '';
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) text += line.translateToString(true) + '\n';
}
await navigator.clipboard.writeText(text.replace(/\n+$/, '\n'));
this.showToast('Copied to clipboard', 'success');
} catch (err) {
this.showToast('Failed to copy', 'error');
}
}
increaseFontSize() {
const current = this.terminal.options.fontSize || 12;
this.setFontSize(Math.min(current + 2, 24));
}
decreaseFontSize() {
const current = this.terminal.options.fontSize || 12;
this.setFontSize(Math.max(current - 2, 8));
}
setFontSize(size) {
this.terminal.options.fontSize = size;
document.getElementById('fontSizeDisplay').textContent = size;
this.fitAddon.fit();
localStorage.setItem('codeman-font-size', size);
// Update overlay font cache and re-render at new cell dimensions
this._localEchoOverlay?.refreshFont();
}
loadFontSize() {
const saved = localStorage.getItem('codeman-font-size');
if (saved) {
const size = parseInt(saved, 10);
if (size >= 8 && size <= 24) {
this.terminal.options.fontSize = size;
document.getElementById('fontSizeDisplay').textContent = size;
}
}
}
/**
* Get terminal dimensions with minimum enforcement.
* Prevents extremely narrow terminals that cause vertical text wrapping.
* @returns {{cols: number, rows: number}|null}
*/
getTerminalDimensions() {
const MIN_COLS = 40;
const MIN_ROWS = 10;
const dims = this.fitAddon?.proposeDimensions();
if (!dims) return null;
return {
cols: Math.max(dims.cols, MIN_COLS),
rows: Math.max(dims.rows, MIN_ROWS)
};
}
/**
* Send resize to a session with minimum dimension enforcement.
* @param {string} sessionId
* @returns {Promise<void>}
*/
async sendResize(sessionId) {
const dims = this.getTerminalDimensions();
if (!dims) return;
await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims)
});
}
/**
* Send input to the active session.
* @param {string} input - Text to send (include \r for Enter)
* @returns {Promise<void>}
*/
async sendInput(input) {
if (!this.activeSessionId) return;
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, useMux: true })
});
}
// ═══════════════════════════════════════════════════════════════
// Timer
// ═══════════════════════════════════════════════════════════════
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() {
// Debounce at 200ms — token display is non-critical and shouldn't
// compete with input handling on the main thread
if (this._updateTokensTimeout) {
clearTimeout(this._updateTokensTimeout);
}
this._updateTokensTimeout = setTimeout(() => {
this._updateTokensTimeout = null;
this._updateTokensImmediate();
}, 200);
}
_updateTokensImmediate() {
// 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) {
const settings = this.loadAppSettingsFromStorage();
const showCost = settings.showCost ?? false;
tokenEl.textContent = total > 0
? (showCost ? `${display} tokens · $${estimatedCost.toFixed(2)}` : `${display} tokens`)
: '0 tokens';
tokenEl.title = this.globalStats
? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created${showCost ? '\nEstimated cost based on Claude Opus pricing' : ''}`
: `Token usage across active sessions${showCost ? '\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 an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn');
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus');
const enableBtn = document.getElementById('modalEnableRespawnBtn');
const stopBtn = document.getElementById('modalStopRespawnBtn');
if (this.respawnStatus[sessionId]) {
respawnStatus.classList.add('active');
respawnStatus.querySelector('.respawn-status-text').textContent =
this.respawnStatus[sessionId].state || 'Active';
enableBtn.style.display = 'none';
stopBtn.style.display = '';
} else {
respawnStatus.classList.remove('active');
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
enableBtn.style.display = '';
stopBtn.style.display = 'none';
}
// Only show respawn section for claude mode sessions with a running process
const respawnSection = document.getElementById('sessionRespawnSection');
if (session.mode === 'claude' && session.pid) {
respawnSection.style.display = '';
} else {
respawnSection.style.display = 'none';
}
// Hide Claude-specific options for OpenCode sessions
const isOpenCode = session.mode === 'opencode';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
// Reset duration presets to default (unlimited)
this.selectDurationPreset('');
// Populate respawn config from saved state
this.loadSavedRespawnConfig(sessionId);
// Populate auto-compact/clear from session state
document.getElementById('modalAutoCompactEnabled').checked = session.autoCompactEnabled ?? false;
document.getElementById('modalAutoCompactThreshold').value = session.autoCompactThreshold ?? 110000;
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
// Populate session name input
document.getElementById('modalSessionName').value = session.name || '';
// Initialize color picker with current session color
const currentColor = session.color || 'default';
const colorPicker = document.getElementById('sessionColorPicker');
colorPicker?.querySelectorAll('.color-swatch').forEach(s => {
s.classList.toggle('selected', s.dataset.color === currentColor);
});
// Initialize respawn preset dropdown
this.renderPresetDropdown();
document.getElementById('respawnPresetSelect').value = '';
document.getElementById('presetDescriptionHint').textContent = '';
// Hide Ralph/Todo tab and Respawn tab for opencode sessions (not supported)
const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]');
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
if (isOpenCode) {
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
if (respawnTabBtn) respawnTabBtn.style.display = 'none';
// Default to Context tab for opencode sessions since Respawn is hidden
this.switchOptionsTab('context');
} else {
if (ralphTabBtn) ralphTabBtn.style.display = '';
if (respawnTabBtn) respawnTabBtn.style.display = '';
}
// Populate Ralph Wiggum form with current session values (skip for opencode)
if (!isOpenCode) {
const ralphState = this.ralphStates.get(sessionId);
this.populateRalphForm({
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
});
}
const modal = document.getElementById('sessionOptionsModal');
modal.classList.add('active');
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
}
async saveSessionName() {
if (!this.editingSessionId) return;
const name = document.getElementById('modalSessionName').value.trim();
try {
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
} catch (err) {
this.showToast('Failed to save session name: ' + err.message, 'error');
}
}
async autoSaveAutoCompact() {
if (!this.editingSessionId) return;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, {
enabled: document.getElementById('modalAutoCompactEnabled').checked,
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
});
} catch { /* silent */ }
}
async autoSaveAutoClear() {
if (!this.editingSessionId) return;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, {
enabled: document.getElementById('modalAutoClearEnabled').checked,
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
});
} catch { /* silent */ }
}
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
// Update local session state
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.imageWatcherEnabled = enabled;
}
this.showToast(`Image watcher ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle image watcher', 'error');
}
}
async toggleFlickerFilter() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalFlickerFilterEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled });
// Update local session state
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.flickerFilterEnabled = enabled;
}
this.showToast(`Flicker filter ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle flicker filter', 'error');
}
}
async autoSaveRespawnConfig() {
if (!this.editingSessionId) return;
const config = {
updatePrompt: document.getElementById('modalRespawnPrompt').value,
sendClear: document.getElementById('modalRespawnSendClear').checked,
sendInit: document.getElementById('modalRespawnSendInit').checked,
kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined,
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
};
try {
await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config);
} catch {
// Silent save - don't interrupt user
}
}
async loadSavedRespawnConfig(sessionId) {
try {
const res = await fetch(`/api/sessions/${sessionId}/respawn/config`);
const data = await res.json();
if (data.success && data.config) {
const c = data.config;
document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md';
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || '';
document.getElementById('modalRespawnAutoAccept').checked = c.autoAcceptPrompts ?? true;
// Restore duration if set
if (c.durationMinutes) {
const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`);
if (presetBtn) {
this.selectDurationPreset(String(c.durationMinutes));
} else {
this.selectDurationPreset('custom');
document.getElementById('modalRespawnDuration').value = c.durationMinutes;
}
}
}
} catch {
// Ignore - use defaults
}
}
// Handle duration preset selection
selectDurationPreset(value) {
// Remove active from all buttons
document.querySelectorAll('.duration-preset-btn').forEach(btn => btn.classList.remove('active'));
// Find and activate the clicked button
const btn = document.querySelector(`.duration-preset-btn[data-minutes="${value}"]`);
if (btn) btn.classList.add('active');
// Show/hide custom input
const customInput = document.querySelector('.duration-custom-input');
const durationInput = document.getElementById('modalRespawnDuration');
if (value === 'custom') {
customInput.classList.add('visible');
durationInput.focus();
} else {
customInput.classList.remove('visible');
durationInput.value = ''; // Clear custom value when using preset
}
}
// Get selected duration from preset buttons or custom input
getSelectedDuration() {
const customInput = document.querySelector('.duration-custom-input');
const durationInput = document.getElementById('modalRespawnDuration');
if (customInput.classList.contains('visible')) {
// Custom mode - use input value
return durationInput.value ? parseInt(durationInput.value) : null;
} else {
// Preset mode - get from active button
const activeBtn = document.querySelector('.duration-preset-btn.active');
const minutes = activeBtn?.dataset.minutes;
return minutes ? parseInt(minutes) : null;
}
}
// ═══════════════════════════════════════════════════════════════
// Respawn Presets
// ═══════════════════════════════════════════════════════════════
loadRespawnPresets() {
// Custom presets: prefer server-synced cache, fall back to legacy localStorage key
const serverCache = this._serverRespawnPresets;
if (serverCache) return [...BUILTIN_RESPAWN_PRESETS, ...serverCache];
const saved = localStorage.getItem('codeman-respawn-presets');
const custom = saved ? JSON.parse(saved) : [];
return [...BUILTIN_RESPAWN_PRESETS, ...custom];
}
saveRespawnPresets(presets) {
// Only save custom presets (not built-in)
const custom = presets.filter(p => !p.builtIn);
// Update local cache + legacy localStorage
this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync)
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
}
renderPresetDropdown() {
const presets = this.loadRespawnPresets();
const builtinGroup = document.getElementById('builtinPresetsGroup');
const customGroup = document.getElementById('customPresetsGroup');
if (!builtinGroup || !customGroup) return;
// Clear and repopulate
builtinGroup.innerHTML = '';
customGroup.innerHTML = '';
presets.forEach(preset => {
const option = document.createElement('option');
option.value = preset.id;
option.textContent = preset.name;
if (preset.builtIn) {
builtinGroup.appendChild(option);
} else {
customGroup.appendChild(option);
}
});
}
updatePresetDescription() {
const select = document.getElementById('respawnPresetSelect');
const hint = document.getElementById('presetDescriptionHint');
if (!select || !hint) return;
const presetId = select.value;
if (!presetId) {
hint.textContent = '';
return;
}
const presets = this.loadRespawnPresets();
const preset = presets.find(p => p.id === presetId);
hint.textContent = preset?.description || '';
}
loadRespawnPreset() {
const select = document.getElementById('respawnPresetSelect');
const presetId = select?.value;
if (!presetId) {
this.showToast('Please select a preset first', 'warning');
return;
}
const presets = this.loadRespawnPresets();
const preset = presets.find(p => p.id === presetId);
if (!preset) return;
// Populate form fields
document.getElementById('modalRespawnPrompt').value = preset.config.updatePrompt || '';
document.getElementById('modalRespawnSendClear').checked = preset.config.sendClear ?? false;
document.getElementById('modalRespawnSendInit').checked = preset.config.sendInit ?? false;
document.getElementById('modalRespawnKickstart').value = preset.config.kickstartPrompt || '';
document.getElementById('modalRespawnAutoAccept').checked = preset.config.autoAcceptPrompts ?? true;
// Set duration if available
if (preset.durationMinutes) {
this.selectDurationPreset(String(preset.durationMinutes));
}
// Reset select to placeholder
select.value = '';
document.getElementById('presetDescriptionHint').textContent = '';
this.showToast(`Loaded preset: ${preset.name}`, 'info');
}
saveCurrentAsPreset() {
document.getElementById('savePresetModal').classList.add('active');
document.getElementById('presetNameInput').value = '';
document.getElementById('presetDescriptionInput').value = '';
document.getElementById('presetNameInput').focus();
}
closeSavePresetModal() {
document.getElementById('savePresetModal').classList.remove('active');
}
confirmSavePreset() {
const name = document.getElementById('presetNameInput').value.trim();
if (!name) {
this.showToast('Please enter a preset name', 'error');
return;
}
// Get current config from form
const updatePrompt = document.getElementById('modalRespawnPrompt').value;
const sendClear = document.getElementById('modalRespawnSendClear').checked;
const sendInit = document.getElementById('modalRespawnSendInit').checked;
const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined;
const durationMinutes = this.getSelectedDuration();
const newPreset = {
id: 'custom-' + Date.now(),
name,
description: document.getElementById('presetDescriptionInput').value.trim() || undefined,
config: {
idleTimeoutMs: 5000, // Default
updatePrompt,
interStepDelayMs: 3000, // Default
sendClear,
sendInit,
kickstartPrompt,
},
durationMinutes: durationMinutes || undefined,
builtIn: false,
createdAt: Date.now(),
};
const presets = this.loadRespawnPresets();
presets.push(newPreset);
this.saveRespawnPresets(presets);
this.renderPresetDropdown();
this.closeSavePresetModal();
this.showToast(`Saved preset: ${name}`, 'success');
}
deletePreset(presetId) {
const presets = this.loadRespawnPresets();
const preset = presets.find(p => p.id === presetId);
if (!preset || preset.builtIn) {
this.showToast('Cannot delete built-in presets', 'warning');
return;
}
const filtered = presets.filter(p => p.id !== presetId);
this.saveRespawnPresets(filtered);
this.renderPresetDropdown();
this.showToast(`Deleted preset: ${preset.name}`, 'success');
}
// Get respawn config from modal inputs
getModalRespawnConfig() {
const updatePrompt = document.getElementById('modalRespawnPrompt').value;
const sendClear = document.getElementById('modalRespawnSendClear').checked;
const sendInit = document.getElementById('modalRespawnSendInit').checked;
const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined;
const autoAcceptPrompts = document.getElementById('modalRespawnAutoAccept').checked;
const durationMinutes = this.getSelectedDuration();
// Auto-compact settings
const autoCompactEnabled = document.getElementById('modalAutoCompactEnabled').checked;
const autoCompactThreshold = parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000;
const autoCompactPrompt = document.getElementById('modalAutoCompactPrompt').value.trim() || undefined;
// Auto-clear settings
const autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked;
const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000;
return {
respawnConfig: {
enabled: true, // Fix: ensure enabled is set so pre-saved configs with enabled: false get overridden
updatePrompt,
sendClear,
sendInit,
kickstartPrompt,
autoAcceptPrompts,
},
durationMinutes,
autoCompactEnabled,
autoCompactThreshold,
autoCompactPrompt,
autoClearEnabled,
autoClearThreshold
};
}
async enableRespawnFromModal() {
if (!this.editingSessionId) {
this.showToast('No session selected', 'warning');
return;
}
const {
respawnConfig,
durationMinutes,
autoCompactEnabled,
autoCompactThreshold,
autoCompactPrompt,
autoClearEnabled,
autoClearThreshold
} = this.getModalRespawnConfig();
try {
// Enable respawn on the session
const res = await fetch(`/api/sessions/${this.editingSessionId}/respawn/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: respawnConfig, durationMinutes })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
// Set auto-compact if enabled
if (autoCompactEnabled) {
await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: autoCompactThreshold, prompt: autoCompactPrompt })
});
}
// Set auto-clear if enabled
if (autoClearEnabled) {
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
});
}
// Update UI
const respawnStatus = document.getElementById('sessionRespawnStatus');
respawnStatus.classList.add('active');
respawnStatus.querySelector('.respawn-status-text').textContent = 'WATCHING';
document.getElementById('modalEnableRespawnBtn').style.display = 'none';
document.getElementById('modalStopRespawnBtn').style.display = '';
this.showToast('Respawn enabled', 'success');
} catch (err) {
this.showToast('Failed to enable respawn: ' + err.message, 'error');
}
}
async stopRespawnFromModal() {
if (!this.editingSessionId) return;
try {
await fetch(`/api/sessions/${this.editingSessionId}/respawn/stop`, { method: 'POST' });
delete this.respawnTimers[this.editingSessionId];
// Update the modal display
const respawnStatus = document.getElementById('sessionRespawnStatus');
respawnStatus.classList.remove('active');
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
document.getElementById('modalEnableRespawnBtn').style.display = '';
document.getElementById('modalStopRespawnBtn').style.display = 'none';
this.showToast('Respawn stopped', 'success');
} catch (err) {
this.showToast('Failed to stop respawn', 'error');
}
}
closeSessionOptions() {
this.editingSessionId = null;
// Stop run summary auto-refresh if it was running
this.stopRunSummaryAutoRefresh();
document.getElementById('sessionOptionsModal').classList.remove('active');
// Deactivate focus trap and restore focus
if (this.activeFocusTrap) {
this.activeFocusTrap.deactivate();
this.activeFocusTrap = null;
}
}
setupColorPicker() {
const picker = document.getElementById('sessionColorPicker');
if (!picker) return;
picker.addEventListener('click', (e) => {
const swatch = e.target.closest('.color-swatch');
if (!swatch || !this.editingSessionId) return;
const color = swatch.dataset.color;
this.setSessionColor(this.editingSessionId, color);
});
}
async setSessionColor(sessionId, color) {
try {
const res = await fetch(`/api/sessions/${sessionId}/color`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color })
});
if (res.ok) {
const session = this.sessions.get(sessionId);
if (session) {
session.color = color;
this.renderSessionTabs();
}
// Update picker UI to show selection
const picker = document.getElementById('sessionColorPicker');
if (picker) {
picker.querySelectorAll('.color-swatch').forEach(swatch => {
swatch.classList.toggle('selected', swatch.dataset.color === color);
});
}
} else {
this.showToast('Failed to set session color', 'error');
}
} catch (err) {
this.showToast('Failed to set session color', 'error');
}
}
// ═══════════════════════════════════════════════════════════════
// Run Summary Modal
// ═══════════════════════════════════════════════════════════════
async openRunSummary(sessionId) {
// Open session options modal and switch to summary tab
this.openSessionOptions(sessionId);
this.switchOptionsTab('summary');
this.runSummarySessionId = sessionId;
this.runSummaryFilter = 'all';
// Reset filter buttons
document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === 'all');
});
// Load summary data
await this.loadRunSummary(sessionId);
}
closeRunSummary() {
this.runSummarySessionId = null;
this.stopRunSummaryAutoRefresh();
// Close session options modal (summary is now a tab in it)
this.closeSessionOptions();
}
async refreshRunSummary() {
const sessionId = this.runSummarySessionId || this.editingSessionId;
if (!sessionId) return;
await this.loadRunSummary(sessionId);
}
toggleRunSummaryAutoRefresh() {
const checkbox = document.getElementById('runSummaryAutoRefresh');
if (checkbox.checked) {
this.startRunSummaryAutoRefresh();
} else {
this.stopRunSummaryAutoRefresh();
}
}
startRunSummaryAutoRefresh() {
if (this.runSummaryAutoRefreshTimer) return;
this.runSummaryAutoRefreshTimer = setInterval(() => {
if (this.runSummarySessionId) {
this.loadRunSummary(this.runSummarySessionId);
}
}, 5000); // Refresh every 5 seconds
}
stopRunSummaryAutoRefresh() {
if (this.runSummaryAutoRefreshTimer) {
clearInterval(this.runSummaryAutoRefreshTimer);
this.runSummaryAutoRefreshTimer = null;
}
const checkbox = document.getElementById('runSummaryAutoRefresh');
if (checkbox) checkbox.checked = false;
}
exportRunSummary(format) {
if (!this.runSummaryData) {
this.showToast('No summary data to export', 'error');
return;
}
const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData;
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `run-summary-${sessionName || 'session'}-${timestamp}`;
if (format === 'json') {
const json = JSON.stringify(this.runSummaryData, null, 2);
this.downloadFile(`${filename}.json`, json, 'application/json');
} else if (format === 'md') {
const duration = lastUpdatedAt - startedAt;
let md = `# Run Summary: ${sessionName || 'Session'}\n\n`;
md += `**Duration**: ${this.formatDuration(duration)}\n`;
md += `**Started**: ${new Date(startedAt).toLocaleString()}\n`;
md += `**Last Update**: ${new Date(lastUpdatedAt).toLocaleString()}\n\n`;
md += `## Statistics\n\n`;
md += `| Metric | Value |\n`;
md += `|--------|-------|\n`;
md += `| Respawn Cycles | ${stats.totalRespawnCycles} |\n`;
md += `| Peak Tokens | ${this.formatTokens(stats.peakTokens)} |\n`;
md += `| Active Time | ${this.formatDuration(stats.totalTimeActiveMs)} |\n`;
md += `| Idle Time | ${this.formatDuration(stats.totalTimeIdleMs)} |\n`;
md += `| Errors | ${stats.errorCount} |\n`;
md += `| Warnings | ${stats.warningCount} |\n`;
md += `| AI Checks | ${stats.aiCheckCount} |\n`;
md += `| State Transitions | ${stats.stateTransitions} |\n\n`;
md += `## Event Timeline\n\n`;
if (events.length === 0) {
md += `No events recorded.\n`;
} else {
md += `| Time | Type | Severity | Title | Details |\n`;
md += `|------|------|----------|-------|----------|\n`;
for (const event of events) {
const time = new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false });
const details = event.details ? event.details.replace(/\|/g, '\\|') : '-';
md += `| ${time} | ${event.type} | ${event.severity} | ${event.title} | ${details} |\n`;
}
}
this.downloadFile(`${filename}.md`, md, 'text/markdown');
}
this.showToast(`Exported as ${format.toUpperCase()}`, 'success');
}
downloadFile(filename, content, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async loadRunSummary(sessionId) {
const timeline = document.getElementById('runSummaryTimeline');
timeline.innerHTML = '<p class="empty-message">Loading summary...</p>';
try {
const response = await fetch(`/api/sessions/${sessionId}/run-summary`);
const data = await response.json();
if (!data.success) {
timeline.innerHTML = `<p class="empty-message">Failed to load summary: ${escapeHtml(data.error)}</p>`;
return;
}
this.runSummaryData = data.summary;
this.renderRunSummary();
} catch (err) {
console.error('Failed to load run summary:', err);
timeline.innerHTML = '<p class="empty-message">Failed to load summary</p>';
}
}
renderRunSummary() {
if (!this.runSummaryData) return;
const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData;
// Update session info
const duration = lastUpdatedAt - startedAt;
document.getElementById('runSummarySessionInfo').textContent =
`${sessionName || 'Session'} - ${this.formatDuration(duration)} total`;
// Filter and render events
const filteredEvents = this.filterRunSummaryEvents(events);
this.renderRunSummaryTimeline(filteredEvents);
}
filterRunSummaryEvents(events) {
if (this.runSummaryFilter === 'all') return events;
return events.filter(event => {
switch (this.runSummaryFilter) {
case 'errors': return event.severity === 'error';
case 'warnings': return event.severity === 'warning' || event.severity === 'error';
case 'respawn': return event.type.startsWith('respawn_') || event.type === 'state_stuck';
case 'idle': return event.type === 'idle_detected' || event.type === 'working_detected';
default: return true;
}
});
}
filterRunSummary(filter) {
this.runSummaryFilter = filter;
// Update active state on buttons
document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === filter);
});
this.renderRunSummary();
}
renderRunSummaryTimeline(events) {
const timeline = document.getElementById('runSummaryTimeline');
if (!events || events.length === 0) {
timeline.innerHTML = '<p class="empty-message">No events recorded yet</p>';
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 `
<div class="timeline-event ${severityClass}">
<div class="event-icon">${icon}</div>
<div class="event-content">
<div class="event-header">
<span class="event-title">${escapeHtml(event.title)}</span>
<span class="event-time">${time}</span>
</div>
${event.details ? `<div class="event-details">${escapeHtml(event.details)}</div>` : ''}
</div>
</div>
`;
}).join('');
timeline.innerHTML = html;
}
getEventIcon(type, severity) {
if (severity === 'error') return '&#x274C;'; // Red X
if (severity === 'warning') return '&#x26A0;'; // Warning triangle
if (severity === 'success') return '&#x2714;'; // Checkmark
switch (type) {
case 'session_started': return '&#x1F680;'; // Rocket
case 'session_stopped': return '&#x1F6D1;'; // Stop sign
case 'respawn_cycle_started': return '&#x1F504;'; // Cycle
case 'respawn_cycle_completed': return '&#x2705;'; // Green check
case 'respawn_state_change': return '&#x27A1;'; // Arrow
case 'token_milestone': return '&#x1F4B0;'; // Money bag
case 'idle_detected': return '&#x1F4A4;'; // Zzz
case 'working_detected': return '&#x1F4BB;'; // Laptop
case 'ai_check_result': return '&#x1F916;'; // Robot
case 'hook_event': return '&#x1F514;'; // Bell
default: return '&#x2022;'; // Bullet
}
}
formatDuration(ms) {
if (!ms || ms < 0) return '0s';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
saveSessionOptions() {
// Session options are applied immediately via individual controls
// This just closes the modal
this.closeSessionOptions();
}
// ═══════════════════════════════════════════════════════════════
// Session Options Modal Tabs
// ═══════════════════════════════════════════════════════════════
switchOptionsTab(tabName) {
// Toggle active class on tab buttons
document.querySelectorAll('#sessionOptionsModal .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Toggle hidden class on tab content
document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn');
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
// Load run summary data when switching to summary tab
if (tabName === 'summary' && this.editingSessionId) {
this.loadRunSummary(this.editingSessionId);
}
}
getRalphConfig() {
return {
enabled: document.getElementById('modalRalphEnabled').checked,
completionPhrase: document.getElementById('modalRalphPhrase').value.trim(),
maxIterations: parseInt(document.getElementById('modalRalphMaxIterations').value) || 0,
maxTodos: parseInt(document.getElementById('modalRalphMaxTodos').value) || 50,
todoExpirationMinutes: parseInt(document.getElementById('modalRalphTodoExpiration').value) || 60
};
}
populateRalphForm(config) {
document.getElementById('modalRalphEnabled').checked = config?.enabled ?? false;
document.getElementById('modalRalphPhrase').value = config?.completionPhrase || '';
document.getElementById('modalRalphMaxIterations').value = config?.maxIterations || 0;
document.getElementById('modalRalphMaxTodos').value = config?.maxTodos || 50;
document.getElementById('modalRalphTodoExpiration').value = config?.todoExpirationMinutes || 60;
}
async saveRalphConfig() {
if (!this.editingSessionId) {
this.showToast('No session selected', 'warning');
return;
}
const config = this.getRalphConfig();
// If user is enabling Ralph, clear from closed set
if (config.enabled) {
this.ralphClosedSessions.delete(this.editingSessionId);
}
try {
const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await res.json();
if (data.error) throw new Error(data.error);
this.showToast('Ralph config saved', 'success');
} catch (err) {
this.showToast('Failed to save Ralph config: ' + err.message, 'error');
}
}
// Inline rename on right-click
startInlineRename(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return;
const currentName = this.getSessionName(session);
const input = document.createElement('input');
input.type = 'text';
input.value = session.name || '';
input.placeholder = currentName;
input.className = 'tab-rename-input';
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
const originalContent = tabName.textContent;
tabName.textContent = '';
tabName.appendChild(input);
input.focus();
input.select();
const finishRename = async () => {
const newName = input.value.trim();
tabName.textContent = newName || originalContent;
if (newName && newName !== session.name) {
try {
await fetch(`/api/sessions/${sessionId}/name`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
} catch (err) {
tabName.textContent = originalContent;
this.showToast('Failed to rename', 'error');
}
}
};
input.addEventListener('blur', finishRename);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
input.value = '';
input.blur();
}
});
}
// ═══════════════════════════════════════════════════════════════
// Web Push
// ═══════════════════════════════════════════════════════════════
registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then((reg) => {
this._swRegistration = reg;
// Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'notification-click') {
const { sessionId } = event.data;
if (sessionId && this.sessions.has(sessionId)) {
this.selectSession(sessionId);
}
window.focus();
}
});
// Check if already subscribed
reg.pushManager.getSubscription().then((sub) => {
if (sub) {
this._pushSubscription = sub;
this._updatePushUI(true);
}
});
}).catch(() => {
// Service worker registration failed (likely not HTTPS)
});
}
async subscribeToPush() {
if (!this._swRegistration) {
this.showToast('Service worker not available. HTTPS or localhost required.', 'error');
return;
}
try {
// Get VAPID public key from server
const keyData = await this._apiJson('/api/push/vapid-key');
if (!keyData?.success) throw new Error('Failed to get VAPID key');
const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey);
const subscription = await this._swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
// Send subscription to server
const subJson = subscription.toJSON();
const data = await this._apiJson('/api/push/subscribe', {
method: 'POST',
body: {
endpoint: subJson.endpoint,
keys: subJson.keys,
userAgent: navigator.userAgent,
pushPreferences: this._buildPushPreferences(),
},
});
if (!data?.success) throw new Error('Failed to register subscription');
this._pushSubscription = subscription;
this._pushSubscriptionId = data.data.id;
localStorage.setItem('codeman-push-subscription-id', data.data.id);
this._updatePushUI(true);
this.showToast('Push notifications enabled', 'success');
} catch (err) {
this.showToast('Push subscription failed: ' + (err.message || err), 'error');
}
}
async unsubscribeFromPush() {
try {
if (this._pushSubscription) {
await this._pushSubscription.unsubscribe();
}
const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id');
if (subId) {
await fetch(`/api/push/subscribe/${subId}`, { method: 'DELETE' }).catch(() => {});
}
this._pushSubscription = null;
this._pushSubscriptionId = null;
localStorage.removeItem('codeman-push-subscription-id');
this._updatePushUI(false);
this.showToast('Push notifications disabled', 'success');
} catch (err) {
this.showToast('Failed to unsubscribe: ' + (err.message || err), 'error');
}
}
async togglePushSubscription() {
if (this._pushSubscription) {
await this.unsubscribeFromPush();
} else {
await this.subscribeToPush();
}
}
/** Sync push preferences to server */
async _syncPushPreferences() {
const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id');
if (!subId) return;
try {
await fetch(`/api/push/subscribe/${subId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pushPreferences: this._buildPushPreferences() }),
});
} catch {
// Silently fail — prefs saved locally, will sync on next subscribe
}
}
/** Build push preferences object from current event type checkboxes */
_buildPushPreferences() {
const prefs = {};
const eventMap = {
'hook:permission_prompt': 'eventPermissionPush',
'hook:elicitation_dialog': 'eventQuestionPush',
'hook:idle_prompt': 'eventIdlePush',
'hook:stop': 'eventStopPush',
'respawn:blocked': 'eventRespawnPush',
'session:ralphCompletionDetected': 'eventRalphPush',
};
for (const [event, checkboxId] of Object.entries(eventMap)) {
const el = document.getElementById(checkboxId);
prefs[event] = el ? el.checked : true;
}
// session:error always receives push (no per-event toggle, always critical)
prefs['session:error'] = true;
return prefs;
}
_updatePushUI(subscribed) {
const btn = document.getElementById('pushSubscribeBtn');
const status = document.getElementById('pushSubscriptionStatus');
if (btn) btn.textContent = subscribed ? 'Unsubscribe' : 'Subscribe';
if (status) {
status.textContent = subscribed ? 'active' : 'off';
status.classList.remove('granted', 'denied');
if (subscribed) status.classList.add('granted');
}
}
// ═══════════════════════════════════════════════════════════════
// App Settings Modal
// ═══════════════════════════════════════════════════════════════
openAppSettings() {
// Load current settings
const settings = this.loadAppSettingsFromStorage();
document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || '';
document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || '';
// Use device-aware defaults for display settings (mobile has different defaults)
const defaults = this.getDefaultSettings();
document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? defaults.ralphTrackerEnabled ?? false;
// Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true;
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? true;
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
const allowedToolsRow = document.getElementById('allowedToolsRow');
claudeModeSelect.value = settings.claudeMode || 'dangerously-skip-permissions';
document.getElementById('appSettingsAllowedTools').value = settings.allowedTools || '';
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
// Toggle allowed tools row visibility based on mode selection
claudeModeSelect.onchange = () => {
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
};
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
// CPU Priority settings
const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10;
// Model configuration (loaded from server)
this.loadModelConfigForSettings();
// Notification settings
const notifPrefs = this.notificationManager?.preferences || {};
document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true;
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
// Push notification settings
document.getElementById('appSettingsPushEnabled').checked = !!this._pushSubscription;
this._updatePushUI(!!this._pushSubscription);
// Per-event-type preferences
const eventTypes = notifPrefs.eventTypes || {};
// Permission prompts
const permPref = eventTypes.permission_prompt || {};
document.getElementById('eventPermissionEnabled').checked = permPref.enabled ?? true;
document.getElementById('eventPermissionBrowser').checked = permPref.browser ?? true;
document.getElementById('eventPermissionPush').checked = permPref.push ?? false;
document.getElementById('eventPermissionAudio').checked = permPref.audio ?? true;
// Questions (elicitation_dialog)
const questionPref = eventTypes.elicitation_dialog || {};
document.getElementById('eventQuestionEnabled').checked = questionPref.enabled ?? true;
document.getElementById('eventQuestionBrowser').checked = questionPref.browser ?? true;
document.getElementById('eventQuestionPush').checked = questionPref.push ?? false;
document.getElementById('eventQuestionAudio').checked = questionPref.audio ?? true;
// Session idle (idle_prompt)
const idlePref = eventTypes.idle_prompt || {};
document.getElementById('eventIdleEnabled').checked = idlePref.enabled ?? true;
document.getElementById('eventIdleBrowser').checked = idlePref.browser ?? true;
document.getElementById('eventIdlePush').checked = idlePref.push ?? false;
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
// Response complete (stop)
const stopPref = eventTypes.stop || {};
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true;
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
// Respawn cycles
const respawnPref = eventTypes.respawn_cycle || {};
document.getElementById('eventRespawnEnabled').checked = respawnPref.enabled ?? true;
document.getElementById('eventRespawnBrowser').checked = respawnPref.browser ?? false;
document.getElementById('eventRespawnPush').checked = respawnPref.push ?? false;
document.getElementById('eventRespawnAudio').checked = respawnPref.audio ?? false;
// Task complete (ralph_complete)
const ralphPref = eventTypes.ralph_complete || {};
document.getElementById('eventRalphEnabled').checked = ralphPref.enabled ?? true;
document.getElementById('eventRalphBrowser').checked = ralphPref.browser ?? true;
document.getElementById('eventRalphPush').checked = ralphPref.push ?? false;
document.getElementById('eventRalphAudio').checked = ralphPref.audio ?? true;
// Subagent activity (subagent_spawn and subagent_complete)
const subagentPref = eventTypes.subagent_spawn || {};
document.getElementById('eventSubagentEnabled').checked = subagentPref.enabled ?? false;
document.getElementById('eventSubagentBrowser').checked = subagentPref.browser ?? false;
document.getElementById('eventSubagentPush').checked = subagentPref.push ?? false;
document.getElementById('eventSubagentAudio').checked = subagentPref.audio ?? false;
// Update permission status display (compact format for new grid layout)
const permStatus = document.getElementById('notifPermissionStatus');
if (permStatus && typeof Notification !== 'undefined') {
const perm = Notification.permission;
permStatus.textContent = perm === 'granted' ? '\u2713' : perm === 'denied' ? '\u2717' : '?';
permStatus.classList.remove('granted', 'denied');
if (perm === 'granted') permStatus.classList.add('granted');
else if (perm === 'denied') permStatus.classList.add('denied');
}
// Voice settings (loaded from localStorage only)
const voiceCfg = VoiceInput._getDeepgramConfig();
document.getElementById('voiceDeepgramKey').value = voiceCfg.apiKey || '';
document.getElementById('voiceLanguage').value = voiceCfg.language || 'en-US';
document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com';
document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct';
// Reset key visibility to hidden
const keyInput = document.getElementById('voiceDeepgramKey');
keyInput.type = 'password';
document.getElementById('voiceKeyToggleBtn').textContent = 'Show';
// Update provider status
const providerName = VoiceInput.getActiveProviderName();
const providerEl = document.getElementById('voiceProviderStatus');
providerEl.textContent = providerName;
providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : '');
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
const modal = document.getElementById('appSettingsModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchSettingsTab(btn.dataset.tab);
});
modal.classList.add('active');
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
}
switchSettingsTab(tabName) {
const modal = document.getElementById('appSettingsModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
}
closeAppSettings() {
document.getElementById('appSettingsModal').classList.remove('active');
// Deactivate focus trap and restore focus
if (this.activeFocusTrap) {
this.activeFocusTrap.deactivate();
this.activeFocusTrap = null;
}
}
async loadTunnelStatus() {
try {
const res = await fetch('/api/tunnel/status');
const status = await res.json();
const active = status.running && status.url;
this._tunnelUrl = active ? status.url : null;
this._updateTunnelUrlDisplay(this._tunnelUrl);
this._updateWelcomeTunnelBtn(!!active, this._tunnelUrl);
this._updateTunnelIndicator(!!active);
} catch {
this._tunnelUrl = null;
this._updateTunnelUrlDisplay(null);
this._updateWelcomeTunnelBtn(false);
this._updateTunnelIndicator(false);
}
}
_updateTunnelUrlDisplay(url) {
const row = document.getElementById('tunnelUrlRow');
const display = document.getElementById('tunnelUrlDisplay');
if (!row || !display) return;
if (url) {
row.style.display = '';
display.textContent = url;
display.onclick = () => {
navigator.clipboard.writeText(url).then(() => {
this.showToast('Tunnel URL copied', 'success');
});
};
} else {
row.style.display = 'none';
display.textContent = '';
display.onclick = null;
}
// Upload URL row
const uploadRow = document.getElementById('tunnelUploadUrlRow');
const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay');
if (!uploadRow || !uploadDisplay) return;
if (url) {
const uploadUrl = url + '/upload.html';
uploadRow.style.display = '';
uploadDisplay.textContent = uploadUrl;
uploadDisplay.onclick = () => {
navigator.clipboard.writeText(uploadUrl).then(() => {
this.showToast('Upload URL copied', 'success');
});
};
} else {
uploadRow.style.display = 'none';
uploadDisplay.textContent = '';
uploadDisplay.onclick = null;
}
}
showTunnelQR() {
// Close existing popup if open
this.closeTunnelQR();
const overlay = document.createElement('div');
overlay.id = 'tunnelQrOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:pointer';
overlay.onclick = (e) => { if (e.target === overlay) this.closeTunnelQR(); };
const card = document.createElement('div');
card.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center;max-width:340px;width:90vw;box-shadow:var(--shadow-lg);cursor:default';
card.innerHTML = `
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:16px">Scan to connect</div>
<div id="tunnelQrContainer" style="background:#fff;border-radius:8px;padding:16px;display:inline-block">
<div style="color:#666;font-size:12px">Loading...</div>
</div>
<div id="tunnelQrUrl" style="margin-top:12px;font-family:monospace;font-size:11px;color:var(--text-muted);word-break:break-all;cursor:pointer" title="Click to copy"></div>
<button onclick="app.closeTunnelQR()" style="margin-top:16px;padding:6px 20px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);cursor:pointer;font-size:13px">Close</button>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
// Fetch QR SVG from server
fetch('/api/tunnel/qr')
.then(res => {
if (!res.ok) throw new Error('Tunnel not running');
return res.json();
})
.then(data => {
const container = document.getElementById('tunnelQrContainer');
if (container && data.svg) container.innerHTML = data.svg;
// Show auth badge, countdown, and regenerate button when auth is enabled
if (data.authEnabled) {
const badge = document.createElement('div');
badge.id = 'tunnelQrBadge';
badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)';
badge.textContent = 'Single-use auth \u00b7 expires in 60s';
const regenBtn = document.createElement('button');
regenBtn.textContent = 'Regenerate QR';
regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px';
regenBtn.onclick = () => {
fetch('/api/tunnel/qr/regenerate', { method: 'POST' })
.then(() => this.showToast('QR code regenerated', 'success'))
.catch(() => this.showToast('Failed to regenerate QR', 'error'));
};
const card = container.parentElement;
if (card) {
card.appendChild(badge);
card.appendChild(regenBtn);
}
this._resetQrCountdown();
}
})
.catch(() => {
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = '<div style="color:#c00;font-size:12px;padding:20px">Tunnel not active</div>';
});
// Fetch URL for display
fetch('/api/tunnel/status')
.then(r => r.json())
.then(status => {
const urlEl = document.getElementById('tunnelQrUrl');
if (urlEl && status.url) {
urlEl.textContent = status.url;
urlEl.onclick = () => {
navigator.clipboard.writeText(status.url).then(() => {
this.showToast('Tunnel URL copied', 'success');
});
};
}
})
.catch(() => {});
// Close on Escape
this._tunnelQrEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelQR(); };
document.addEventListener('keydown', this._tunnelQrEscHandler);
}
closeTunnelQR() {
const overlay = document.getElementById('tunnelQrOverlay');
if (overlay) overlay.remove();
if (this._tunnelQrEscHandler) {
document.removeEventListener('keydown', this._tunnelQrEscHandler);
this._tunnelQrEscHandler = null;
}
this._clearQrCountdown();
}
/** Fallback: fetch QR SVG from API when SSE payload lacks it */
_refreshTunnelQrFromApi() {
fetch('/api/tunnel/qr')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (!data?.svg) return;
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = data.svg;
const welcomeInner = document.getElementById('welcomeQrInner');
if (welcomeInner) welcomeInner.innerHTML = data.svg;
})
.catch(() => {});
}
/** Start or reset the 60s countdown on the QR badge */
_resetQrCountdown() {
this._clearQrCountdown();
this._qrCountdownSec = 60;
this._updateQrCountdownText();
this._qrCountdownTimer = setInterval(() => {
this._qrCountdownSec--;
if (this._qrCountdownSec <= 0) {
this._clearQrCountdown();
return;
}
this._updateQrCountdownText();
}, 1000);
}
_updateQrCountdownText() {
const badge = document.getElementById('tunnelQrBadge');
if (badge) {
badge.textContent = `Single-use auth \u00b7 expires in ${this._qrCountdownSec}s`;
}
}
_clearQrCountdown() {
if (this._qrCountdownTimer) {
clearInterval(this._qrCountdownTimer);
this._qrCountdownTimer = null;
}
}
async toggleTunnelFromWelcome() {
const btn = document.getElementById('welcomeTunnelBtn');
if (!btn) return;
const isActive = btn.classList.contains('active');
btn.disabled = true;
try {
const newEnabled = !isActive;
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
});
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
this._pollTunnelStatus();
} else {
this._dismissTunnelConnecting();
this.showToast('Tunnel stopped', 'info');
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
}
} catch (err) {
this._dismissTunnelConnecting();
this.showToast('Failed to toggle tunnel', 'error');
btn.disabled = false;
}
}
_showTunnelConnecting() {
// Remove any existing connecting toast first (without resetting button state)
const oldToast = document.getElementById('tunnelConnectingToast');
if (oldToast) {
oldToast.remove();
}
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) {
btn.classList.add('connecting');
btn.innerHTML = `
<span class="tunnel-spinner"></span>
Connecting...`;
}
// Persistent toast with spinner
const toast = document.createElement('div');
toast.className = 'toast toast-info show';
toast.id = 'tunnelConnectingToast';
toast.innerHTML = '<span class="tunnel-spinner"></span> Cloudflare Tunnel connecting...';
toast.style.pointerEvents = 'auto';
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
this._toastContainer.appendChild(toast);
}
_dismissTunnelConnecting() {
clearTimeout(this._tunnelPollTimer);
this._tunnelPollTimer = null;
const toast = document.getElementById('tunnelConnectingToast');
if (toast) {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 200);
}
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) btn.classList.remove('connecting');
}
_pollTunnelStatus(attempt = 0) {
if (attempt > 15) return; // give up after ~30s
this._tunnelPollTimer = setTimeout(async () => {
try {
const res = await fetch('/api/tunnel/status');
const status = await res.json();
if (status.running && status.url) {
// Tunnel is up — update UI
this._dismissTunnelConnecting();
this._updateTunnelUrlDisplay(status.url);
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
if (welcomeVisible) {
this._updateWelcomeTunnelBtn(true, status.url, true);
this.showToast('Tunnel active', 'success');
} else {
this._updateWelcomeTunnelBtn(true, status.url);
this.showToast(`Tunnel active: ${status.url}`, 'success');
this.showTunnelQR();
}
return;
}
} catch { /* ignore */ }
this._pollTunnelStatus(attempt + 1);
}, 2000);
}
_updateWelcomeTunnelBtn(active, url, firstAppear = false) {
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) {
btn.disabled = false;
if (active) {
btn.classList.remove('connecting');
btn.classList.add('active');
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Tunnel Active`;
} else {
btn.classList.remove('active', 'connecting');
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel`;
}
}
// Update welcome QR code
const qrWrap = document.getElementById('welcomeQr');
const qrInner = document.getElementById('welcomeQrInner');
const qrUrl = document.getElementById('welcomeQrUrl');
if (!qrWrap || !qrInner) return;
if (active) {
qrWrap.classList.add('visible');
// First appear: start expanded, auto-shrink after 8s
if (firstAppear) {
qrWrap.classList.add('expanded');
clearTimeout(this._welcomeQrShrinkTimer);
this._welcomeQrShrinkTimer = setTimeout(() => {
qrWrap.classList.remove('expanded');
}, 8000);
}
if (url) {
qrUrl.textContent = url;
qrUrl.title = 'Click QR to enlarge';
}
fetch('/api/tunnel/qr')
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => { if (data.svg) qrInner.innerHTML = data.svg; })
.catch(() => { qrInner.innerHTML = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>'; });
} else {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('visible', 'expanded');
qrInner.innerHTML = '';
if (qrUrl) qrUrl.textContent = '';
}
}
toggleWelcomeQrSize() {
const qrWrap = document.getElementById('welcomeQr');
if (qrWrap) {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.toggle('expanded');
}
}
// ═══════════════════════════════════════════════════════════════
// Tunnel Header Indicator & Panel (desktop only)
// ═══════════════════════════════════════════════════════════════
_updateTunnelIndicator(active) {
if (MobileDetection.getDeviceType() === 'mobile') return;
const indicator = document.getElementById('tunnelIndicator');
if (!indicator) return;
indicator.style.display = active ? 'flex' : 'none';
indicator.classList.remove('connecting');
}
toggleTunnelPanel() {
const existing = document.getElementById('tunnelPanel');
if (existing) {
this.closeTunnelPanel();
return;
}
this._openTunnelPanel();
}
async _openTunnelPanel() {
const panel = document.createElement('div');
panel.className = 'tunnel-panel';
panel.id = 'tunnelPanel';
panel.innerHTML = `
<div class="tunnel-panel-header">
<h3>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
<span class="tunnel-panel-status" id="tunnelPanelStatus">Loading...</span>
</h3>
</div>
<div class="tunnel-panel-body" id="tunnelPanelBody">
<div style="font-size:12px;color:var(--text-muted);padding:8px 0">Loading...</div>
</div>
`;
document.body.appendChild(panel);
// Close on outside click
this._tunnelPanelClickHandler = (e) => {
if (!panel.contains(e.target) && e.target.id !== 'tunnelIndicator' && !e.target.closest('.tunnel-indicator')) {
this.closeTunnelPanel();
}
};
setTimeout(() => document.addEventListener('click', this._tunnelPanelClickHandler), 0);
// Close on Escape
this._tunnelPanelEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelPanel(); };
document.addEventListener('keydown', this._tunnelPanelEscHandler);
// Fetch tunnel info
try {
const res = await fetch('/api/tunnel/info');
const info = await res.json();
this._renderTunnelPanel(info);
} catch {
const body = document.getElementById('tunnelPanelBody');
if (body) body.innerHTML = '<div style="font-size:12px;color:var(--red);padding:8px 0">Failed to load tunnel info</div>';
}
}
_renderTunnelPanel(info) {
const statusEl = document.getElementById('tunnelPanelStatus');
const body = document.getElementById('tunnelPanelBody');
if (!statusEl || !body) return;
statusEl.textContent = info.running ? 'Connected' : 'Offline';
statusEl.className = 'tunnel-panel-status' + (info.running ? '' : ' offline');
let html = '';
// URL section
if (info.url) {
html += `
<div class="tunnel-panel-section">
<div class="tunnel-panel-label">URL</div>
<div class="tunnel-panel-url" id="tunnelPanelUrl" title="Click to copy">${escapeHtml(info.url)}</div>
</div>`;
}
// Clients section
html += `
<div class="tunnel-panel-section">
<div class="tunnel-panel-label">Connections</div>
<div class="tunnel-panel-stat">
<span>Remote Clients</span>
<span class="tunnel-panel-stat-value">${info.sseClients}</span>
</div>`;
if (info.authEnabled) {
html += `
<div class="tunnel-panel-stat">
<span>Auth Sessions</span>
<span class="tunnel-panel-stat-value">${info.authSessions.length}</span>
</div>`;
}
html += '</div>';
// Auth sessions detail
if (info.authEnabled && info.authSessions.length > 0) {
html += '<div class="tunnel-panel-section"><div class="tunnel-panel-label">Authenticated Devices</div>';
for (const s of info.authSessions) {
const ua = s.ua || 'Unknown';
const browser = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser';
const ago = this._formatTimeAgo(s.createdAt);
html += `
<div class="tunnel-panel-session">
<span class="tunnel-panel-session-dot"></span>
<span class="tunnel-panel-session-info" title="${escapeHtml(ua)}">${escapeHtml(browser)} &middot; ${escapeHtml(s.ip)} &middot; ${ago}</span>
<span class="tunnel-panel-session-method">${s.method}</span>
</div>`;
}
html += '</div>';
}
// Actions
html += '<div class="tunnel-panel-actions">';
if (info.running) {
html += `
<button class="tunnel-panel-btn btn-qr" onclick="app.showTunnelQR();app.closeTunnelPanel()">QR Code</button>
<button class="tunnel-panel-btn btn-stop" onclick="app._tunnelPanelToggle(false)">Stop Tunnel</button>`;
} else {
html += `<button class="tunnel-panel-btn btn-start" onclick="app._tunnelPanelToggle(true)">Start Tunnel</button>`;
}
html += '</div>';
// Revoke all sessions button
if (info.authEnabled && info.authSessions.length > 0) {
html += `
<div style="padding-top:8px">
<button class="tunnel-panel-btn btn-revoke" style="width:100%" onclick="app._tunnelPanelRevokeAll()">Revoke All Sessions</button>
</div>`;
}
body.innerHTML = html;
// Bind URL copy handler
const urlEl = document.getElementById('tunnelPanelUrl');
if (urlEl) {
urlEl.onclick = () => {
navigator.clipboard.writeText(info.url).then(() => this.showToast('Tunnel URL copied', 'success'));
};
}
}
_formatTimeAgo(timestamp) {
const diff = Date.now() - timestamp;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
async _tunnelPanelToggle(enable) {
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
});
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
if (indicator) {
indicator.style.display = 'flex';
indicator.classList.add('connecting');
}
this.showToast('Tunnel starting...', 'info');
this._showTunnelConnecting();
this._pollTunnelStatus();
} else {
this.showToast('Tunnel stopped', 'info');
}
this.closeTunnelPanel();
} catch {
this.showToast('Failed to toggle tunnel', 'error');
}
}
async _tunnelPanelRevokeAll() {
try {
await fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
this.showToast('All sessions revoked', 'success');
// Refresh panel
const res = await fetch('/api/tunnel/info');
const info = await res.json();
this._renderTunnelPanel(info);
} catch {
this.showToast('Failed to revoke sessions', 'error');
}
}
closeTunnelPanel() {
const panel = document.getElementById('tunnelPanel');
if (panel) panel.remove();
if (this._tunnelPanelClickHandler) {
document.removeEventListener('click', this._tunnelPanelClickHandler);
this._tunnelPanelClickHandler = null;
}
if (this._tunnelPanelEscHandler) {
document.removeEventListener('keydown', this._tunnelPanelEscHandler);
this._tunnelPanelEscHandler = null;
}
}
toggleDeepgramKeyVisibility() {
const input = document.getElementById('voiceDeepgramKey');
const btn = document.getElementById('voiceKeyToggleBtn');
if (input.type === 'password') {
input.type = 'text';
btn.textContent = 'Hide';
} else {
input.type = 'password';
btn.textContent = 'Show';
}
}
// ═══════════════════════════════════════════════════════════════
// Session Lifecycle Log
// ═══════════════════════════════════════════════════════════════
openLifecycleLog() {
const win = document.getElementById('lifecycleWindow');
win.style.display = 'block';
// Reset transform so it appears centered initially
if (!win._dragInitialized) {
win.style.left = '50%';
win.style.transform = 'translateX(-50%)';
this._initLifecycleDrag(win);
win._dragInitialized = true;
}
this.loadLifecycleLog();
}
closeLifecycleLog() {
document.getElementById('lifecycleWindow').style.display = 'none';
}
_initLifecycleDrag(win) {
const header = document.getElementById('lifecycleWindowHeader');
let isDragging = false;
let startX, startY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
isDragging = true;
// Clear transform so left/top work in absolute pixels
const rect = win.getBoundingClientRect();
win.style.transform = 'none';
win.style.left = rect.left + 'px';
win.style.top = rect.top + 'px';
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
win.style.left = (startLeft + e.clientX - startX) + 'px';
win.style.top = (startTop + e.clientY - startY) + 'px';
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
}
async loadLifecycleLog() {
const eventFilter = document.getElementById('lifecycleFilterEvent').value;
const sessionFilter = document.getElementById('lifecycleFilterSession').value.trim();
const params = new URLSearchParams();
if (eventFilter) params.set('event', eventFilter);
if (sessionFilter) params.set('sessionId', sessionFilter);
params.set('limit', '300');
try {
const res = await fetch(`/api/session-lifecycle?${params}`);
const data = await res.json();
const tbody = document.getElementById('lifecycleTableBody');
const empty = document.getElementById('lifecycleEmpty');
if (!data.entries || data.entries.length === 0) {
tbody.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
const eventColors = {
created: '#4ade80', started: '#4ade80', recovered: '#4ade80',
exit: '#fbbf24', mux_died: '#f87171', deleted: '#f87171', stale_cleaned: '#f87171',
server_started: '#666', server_stopped: '#666',
};
tbody.innerHTML = data.entries.map(e => {
const time = new Date(e.ts).toLocaleString();
const color = eventColors[e.event] || '#888';
const name = e.name || (e.sessionId === '*' ? '—' : this.getShortId(e.sessionId));
const extra = [];
if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`);
if (e.mode) extra.push(e.mode);
return `<tr style="border-bottom:1px solid #1a1a2e">
<td style="padding:3px 8px;color:#888;white-space:nowrap">${time}</td>
<td style="padding:3px 8px;color:${color};font-weight:600">${e.event}</td>
<td style="padding:3px 8px;color:#e0e0e0" title="${e.sessionId}">${name}</td>
<td style="padding:3px 8px;color:#aaa">${e.reason || ''}</td>
<td style="padding:3px 8px;color:#666">${extra.join(', ')}</td>
</tr>`;
}).join('');
} catch (err) {
console.error('Failed to load lifecycle log:', err);
}
}
async saveAppSettings() {
const settings = {
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked,
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
showCost: document.getElementById('appSettingsShowCost').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
niceValue: parseInt(document.getElementById('appSettingsNiceValue').value) || 10,
},
};
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
// Save voice settings to localStorage + include in server payload for cross-device sync
const voiceSettings = {
apiKey: document.getElementById('voiceDeepgramKey').value.trim(),
language: document.getElementById('voiceLanguage').value,
keyterms: document.getElementById('voiceKeyterms').value.trim(),
insertMode: document.getElementById('voiceInsertMode').value,
};
VoiceInput._saveDeepgramConfig(voiceSettings);
// Save notification preferences separately
const notifPrefsToSave = {
enabled: document.getElementById('appSettingsNotifEnabled').checked,
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
// Per-event-type preferences
eventTypes: {
permission_prompt: {
enabled: document.getElementById('eventPermissionEnabled').checked,
browser: document.getElementById('eventPermissionBrowser').checked,
push: document.getElementById('eventPermissionPush').checked,
audio: document.getElementById('eventPermissionAudio').checked,
},
elicitation_dialog: {
enabled: document.getElementById('eventQuestionEnabled').checked,
browser: document.getElementById('eventQuestionBrowser').checked,
push: document.getElementById('eventQuestionPush').checked,
audio: document.getElementById('eventQuestionAudio').checked,
},
idle_prompt: {
enabled: document.getElementById('eventIdleEnabled').checked,
browser: document.getElementById('eventIdleBrowser').checked,
push: document.getElementById('eventIdlePush').checked,
audio: document.getElementById('eventIdleAudio').checked,
},
stop: {
enabled: document.getElementById('eventStopEnabled').checked,
browser: document.getElementById('eventStopBrowser').checked,
push: document.getElementById('eventStopPush').checked,
audio: document.getElementById('eventStopAudio').checked,
},
session_error: {
enabled: true,
browser: this.notificationManager?.preferences?.eventTypes?.session_error?.browser ?? true,
push: this.notificationManager?.preferences?.eventTypes?.session_error?.push ?? false,
audio: false,
},
respawn_cycle: {
enabled: document.getElementById('eventRespawnEnabled').checked,
browser: document.getElementById('eventRespawnBrowser').checked,
push: document.getElementById('eventRespawnPush').checked,
audio: document.getElementById('eventRespawnAudio').checked,
},
token_milestone: {
enabled: true,
browser: false,
push: false,
audio: false,
},
ralph_complete: {
enabled: document.getElementById('eventRalphEnabled').checked,
browser: document.getElementById('eventRalphBrowser').checked,
push: document.getElementById('eventRalphPush').checked,
audio: document.getElementById('eventRalphAudio').checked,
},
subagent_spawn: {
enabled: document.getElementById('eventSubagentEnabled').checked,
browser: document.getElementById('eventSubagentBrowser').checked,
push: document.getElementById('eventSubagentPush').checked,
audio: document.getElementById('eventSubagentAudio').checked,
},
subagent_complete: {
enabled: document.getElementById('eventSubagentEnabled').checked,
browser: document.getElementById('eventSubagentBrowser').checked,
push: document.getElementById('eventSubagentPush').checked,
audio: document.getElementById('eventSubagentAudio').checked,
},
},
_version: 4,
};
if (this.notificationManager) {
this.notificationManager.preferences = notifPrefsToSave;
this.notificationManager.savePreferences();
}
// Sync push preferences to server
this._syncPushPreferences();
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
// Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
const { localEchoEnabled: _leo, ...serverSettings } = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
// Save model configuration separately
await this.saveModelConfigFromSettings();
this.showToast('Settings saved', 'success');
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
// Server save failed but localStorage succeeded
this.showToast('Settings saved locally', 'warning');
}
this.closeAppSettings();
}
// Load model configuration from server for the settings modal
async loadModelConfigForSettings() {
try {
const res = await fetch('/api/execution/model-config');
const data = await res.json();
if (data.success && data.data) {
const config = data.data;
// Default model
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
if (defaultModelEl) {
defaultModelEl.value = config.defaultModel || 'opus';
}
// Show recommendations
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
if (showRecsEl) {
showRecsEl.checked = config.showRecommendations ?? true;
}
// Agent type overrides
const overrides = config.agentTypeOverrides || {};
const exploreEl = document.getElementById('appSettingsModelExplore');
const implementEl = document.getElementById('appSettingsModelImplement');
const testEl = document.getElementById('appSettingsModelTest');
const reviewEl = document.getElementById('appSettingsModelReview');
if (exploreEl) exploreEl.value = overrides.explore || '';
if (implementEl) implementEl.value = overrides.implement || '';
if (testEl) testEl.value = overrides.test || '';
if (reviewEl) reviewEl.value = overrides.review || '';
}
} catch (err) {
console.warn('Failed to load model config:', err);
}
}
// Save model configuration from settings modal to server
async saveModelConfigFromSettings() {
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
const exploreEl = document.getElementById('appSettingsModelExplore');
const implementEl = document.getElementById('appSettingsModelImplement');
const testEl = document.getElementById('appSettingsModelTest');
const reviewEl = document.getElementById('appSettingsModelReview');
const agentTypeOverrides = {};
if (exploreEl?.value) agentTypeOverrides.explore = exploreEl.value;
if (implementEl?.value) agentTypeOverrides.implement = implementEl.value;
if (testEl?.value) agentTypeOverrides.test = testEl.value;
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = {
defaultModel: defaultModelEl?.value || 'opus',
showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides,
};
try {
await fetch('/api/execution/model-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
} catch (err) {
console.warn('Failed to save model config:', err);
}
}
// ═══════════════════════════════════════════════════════════════
// Visibility Settings & Device-Specific Defaults
// ═══════════════════════════════════════════════════════════════
// Get the global Ralph tracker enabled setting
isRalphTrackerEnabledByDefault() {
const settings = this.loadAppSettingsFromStorage();
return settings.ralphTrackerEnabled ?? false;
}
// Get the settings storage key based on device type (mobile vs desktop)
getSettingsStorageKey() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
}
// Get default settings based on device type
// Note: Notification prefs are handled separately by NotificationManager
getDefaultSettings() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
if (isMobile) {
// Mobile defaults: minimal UI for small screens
return {
// Header visibility - hide everything on mobile
showFontControls: false,
showSystemStats: false,
showTokenCount: false,
showCost: false,
// Panel visibility - hide panels on mobile (not enough space)
showMonitor: false,
showProjectInsights: false,
showFileBrowser: false,
showSubagents: false,
// Feature toggles - keep tracking on even on mobile
subagentTrackingEnabled: true,
subagentActiveTabOnly: true, // Only show subagents for active tab
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
};
}
// Desktop defaults - rely on ?? operators in apply functions
// This allows desktop to have different defaults without duplication
return {};
}
loadAppSettingsFromStorage() {
// Return cached settings if available (avoids synchronous localStorage + JSON.parse
// on every SSE event — critical for input responsiveness)
if (this._cachedAppSettings) return this._cachedAppSettings;
try {
const key = this.getSettingsStorageKey();
const saved = localStorage.getItem(key);
if (saved) {
this._cachedAppSettings = JSON.parse(saved);
return this._cachedAppSettings;
}
} catch (err) {
console.error('Failed to load app settings:', err);
}
// Return device-specific defaults
this._cachedAppSettings = this.getDefaultSettings();
return this._cachedAppSettings;
}
saveAppSettingsToStorage(settings) {
// Invalidate cache on save
this._cachedAppSettings = settings;
try {
const key = this.getSettingsStorageKey();
localStorage.setItem(key, JSON.stringify(settings));
} catch (err) {
console.error('Failed to save app settings:', err);
}
}
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const showFontControls = settings.showFontControls ?? defaults.showFontControls ?? false;
const showSystemStats = settings.showSystemStats ?? defaults.showSystemStats ?? true;
const showTokenCount = settings.showTokenCount ?? defaults.showTokenCount ?? true;
const fontControlsEl = document.querySelector('.header-font-controls');
const systemStatsEl = document.getElementById('headerSystemStats');
const tokenCountEl = document.getElementById('headerTokens');
if (fontControlsEl) {
fontControlsEl.style.display = showFontControls ? '' : 'none';
}
if (systemStatsEl) {
systemStatsEl.style.display = showSystemStats ? '' : 'none';
}
if (tokenCountEl) {
tokenCountEl.style.display = showTokenCount ? '' : 'none';
}
// Hide lifecycle log button when setting is disabled
const showLifecycleLog = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
const lifecycleBtn = document.querySelector('.btn-lifecycle-log');
if (lifecycleBtn) {
lifecycleBtn.style.display = showLifecycleLog ? '' : 'none';
}
// Hide notification bell when notifications are disabled
const notifEnabled = this.notificationManager?.preferences?.enabled ?? true;
const notifBtn = document.querySelector('.btn-notifications');
if (notifBtn) {
notifBtn.style.display = notifEnabled ? '' : 'none';
}
// Close the drawer if notifications got disabled while it's open
if (!notifEnabled) {
const drawer = document.getElementById('notifDrawer');
if (drawer) drawer.classList.remove('open');
}
}
applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const deviceType = MobileDetection.getDeviceType();
// Two-row tabs disabled on mobile/tablet — not enough screen space
const twoRows = deviceType === 'desktop'
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
: false;
const prevTallTabs = this._tallTabsEnabled;
this._tallTabsEnabled = twoRows;
const tabsEl = document.getElementById('sessionTabs');
if (tabsEl) {
tabsEl.classList.toggle('tabs-two-rows', twoRows);
tabsEl.classList.toggle('tabs-show-folder', twoRows);
}
// Re-render tabs if folder visibility changed (folder spans are generated in JS)
if (prevTallTabs !== undefined && prevTallTabs !== twoRows) {
this._fullRenderSessionTabs();
}
}
applyMonitorVisibility() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true;
const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? true;
const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
const monitorPanel = document.getElementById('monitorPanel');
if (monitorPanel) {
monitorPanel.style.display = showMonitor ? '' : 'none';
if (showMonitor) {
monitorPanel.classList.add('open');
} else {
monitorPanel.classList.remove('open');
}
}
const subagentsPanel = document.getElementById('subagentsPanel');
if (subagentsPanel) {
if (showSubagents) {
subagentsPanel.classList.remove('hidden');
} else {
subagentsPanel.classList.add('hidden');
}
}
// File browser panel visibility
const fileBrowserPanel = document.getElementById('fileBrowserPanel');
if (fileBrowserPanel) {
if (showFileBrowser && this.activeSessionId) {
fileBrowserPanel.classList.add('visible');
this.loadFileBrowser(this.activeSessionId);
// Attach drag listeners if not already attached
if (!this.fileBrowserDragListeners) {
const header = fileBrowserPanel.querySelector('.file-browser-header');
if (header) {
// Convert right-positioned to left/top before drag so makeWindowDraggable works
const onFirstDrag = () => {
if (!fileBrowserPanel.style.left) {
const rect = fileBrowserPanel.getBoundingClientRect();
fileBrowserPanel.style.left = `${rect.left}px`;
fileBrowserPanel.style.top = `${rect.top}px`;
fileBrowserPanel.style.right = 'auto';
}
};
header.addEventListener('mousedown', onFirstDrag);
header.addEventListener('touchstart', onFirstDrag, { passive: true });
this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header);
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
} else {
fileBrowserPanel.classList.remove('visible');
}
}
}
closeMonitor() {
// Hide the monitor panel
const monitorPanel = document.getElementById('monitorPanel');
if (monitorPanel) {
monitorPanel.classList.remove('open');
monitorPanel.style.display = 'none';
}
// Save the setting
const settings = this.loadAppSettingsFromStorage();
settings.showMonitor = false;
this.saveAppSettingsToStorage(settings);
}
closeSubagentsPanel() {
// Hide the subagents panel
const subagentsPanel = document.getElementById('subagentsPanel');
if (subagentsPanel) {
subagentsPanel.classList.remove('open');
subagentsPanel.classList.add('hidden');
}
this.subagentPanelVisible = false;
// Save the setting
const settings = this.loadAppSettingsFromStorage();
settings.showSubagents = false;
this.saveAppSettingsToStorage(settings);
}
async clearAllSubagents() {
const count = this.subagents.size;
if (count === 0) {
this.showToast('No subagents to clear', 'info');
return;
}
if (!confirm(`Clear all ${count} tracked subagent(s)? This removes them from the UI but does not affect running processes.`)) {
return;
}
try {
const res = await fetch('/api/subagents', { method: 'DELETE' });
const data = await res.json();
if (data.success) {
// Clear local state
this.subagents.clear();
this.subagentActivity.clear();
this.subagentToolResults.clear();
// Close any open subagent windows
this.cleanupAllFloatingWindows();
// Update UI
this.renderSubagentPanel();
this.renderMonitorSubagents();
this.updateSubagentBadge();
this.showToast(`Cleared ${data.data.cleared} subagent(s)`, 'success');
} else {
this.showToast('Failed to clear subagents: ' + data.error, 'error');
}
} catch (err) {
this.showToast('Failed to clear subagents', 'error');
}
}
toggleSubagentsPanel() {
const panel = document.getElementById('subagentsPanel');
const toggleBtn = document.getElementById('subagentsToggleBtn');
if (!panel) return;
// If hidden, show it first
if (panel.classList.contains('hidden')) {
panel.classList.remove('hidden');
// Save setting
const settings = this.loadAppSettingsFromStorage();
settings.showSubagents = true;
this.saveAppSettingsToStorage(settings);
}
// Toggle open/collapsed state
panel.classList.toggle('open');
this.subagentPanelVisible = panel.classList.contains('open');
// Update toggle button icon
if (toggleBtn) {
toggleBtn.innerHTML = this.subagentPanelVisible ? '&#x25BC;' : '&#x25B2;'; // Down when open, up when collapsed
}
if (this.subagentPanelVisible) {
this.renderSubagentPanel();
}
}
async loadAppSettingsFromServer(settingsPromise = null) {
try {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null);
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
// Filter out display settings — these are device-specific (mobile vs desktop)
// and should not be synced from the server to avoid overriding mobile defaults.
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled',
]);
// Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
const localSettings = this.loadAppSettingsFromStorage();
const merged = { ...localSettings };
for (const [key, value] of Object.entries(appSettings)) {
if (displayKeys.has(key)) {
// Display keys: only use server value as initial seed
if (!(key in localSettings)) {
merged[key] = value;
}
} else {
// Non-display keys: server always wins
merged[key] = value;
}
}
this.saveAppSettingsToStorage(merged);
// Apply notification prefs from server if present (only if localStorage has none)
if (notificationPreferences && this.notificationManager) {
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
if (!localNotifPrefs) {
this.notificationManager.preferences = notificationPreferences;
this.notificationManager.savePreferences();
}
}
// Sync voice settings from server (seed localStorage if no local API key)
if (voiceSettings) {
const localVoice = localStorage.getItem('codeman-voice-settings');
if (!localVoice || !JSON.parse(localVoice).apiKey) {
VoiceInput._saveDeepgramConfig(voiceSettings);
}
}
// Sync respawn presets from server (server is source of truth)
if (respawnPresets && Array.isArray(respawnPresets)) {
this._serverRespawnPresets = respawnPresets;
// Also update localStorage for offline access
localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets));
} else {
// Migration: push existing localStorage presets to server
const localPresets = localStorage.getItem('codeman-respawn-presets');
if (localPresets) {
const parsed = JSON.parse(localPresets);
if (parsed.length > 0) {
this._serverRespawnPresets = parsed;
this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {});
}
}
}
// Sync run mode from server
if (runMode) {
this.runMode = runMode;
try { localStorage.setItem('codeman_runMode', runMode); } catch {}
this._applyRunMode();
}
return merged;
}
} catch (err) {
console.error('Failed to load settings from server:', err);
}
return this.loadAppSettingsFromStorage();
}
/**
* Load subagent window states from server (or localStorage fallback).
* Called on page load to restore minimized/open window states.
*/
async loadSubagentWindowStates() {
let states = null;
// Try server first for cross-browser sync
try {
const res = await fetch('/api/subagent-window-states');
if (res.ok) {
states = await res.json();
// Also update localStorage
localStorage.setItem('codeman-subagent-window-states', JSON.stringify(states));
}
} catch (err) {
console.error('Failed to load subagent window states from server:', err);
}
// Fallback to localStorage
if (!states) {
try {
const saved = localStorage.getItem('codeman-subagent-window-states');
if (saved) {
states = JSON.parse(saved);
}
} catch (err) {
console.error('Failed to load subagent window states from localStorage:', err);
}
}
return states || { minimized: {}, open: [] };
}
// ═══════════════════════════════════════════════════════════════
// Persistent Parent Associations
// ═══════════════════════════════════════════════════════════════
// This is the ROCK-SOLID system for tracking which tab an agent belongs to.
// Once an agent's parent is discovered, it's saved here PERMANENTLY.
/**
* Save the subagent parent map to localStorage and server.
* Called whenever a new parent association is discovered.
*/
async saveSubagentParentMap() {
const mapData = Object.fromEntries(this.subagentParentMap);
// Save to localStorage for instant recovery
try {
localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData));
} catch (err) {
console.error('Failed to save subagent parents to localStorage:', err);
}
// Save to server for cross-browser/session persistence
try {
await fetch('/api/subagent-parents', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mapData)
});
} catch (err) {
console.error('Failed to save subagent parents to server:', err);
}
}
/**
* Load the subagent parent map from server (or localStorage fallback).
* Called once on page load, before any agents are discovered.
*/
async loadSubagentParentMap() {
let mapData = null;
// Try server first (most authoritative)
try {
const res = await fetch('/api/subagent-parents');
if (res.ok) {
mapData = await res.json();
// Update localStorage as cache
localStorage.setItem('codeman-subagent-parents', JSON.stringify(mapData));
}
} catch (err) {
console.error('Failed to load subagent parents from server:', err);
}
// Fallback to localStorage
if (!mapData) {
try {
const saved = localStorage.getItem('codeman-subagent-parents');
if (saved) {
mapData = JSON.parse(saved);
}
} catch (err) {
console.error('Failed to load subagent parents from localStorage:', err);
}
}
// Populate the map (prune stale entries: require both session and agent to exist)
if (mapData && typeof mapData === 'object') {
for (const [agentId, sessionId] of Object.entries(mapData)) {
if (this.sessions.has(sessionId) && this.subagents.has(agentId)) {
this.subagentParentMap.set(agentId, sessionId);
}
}
}
}
/**
* Get the parent session ID for an agent from the persistent map.
* This is the ONLY source of truth for connection lines.
*/
getAgentParentSessionId(agentId) {
return this.subagentParentMap.get(agentId) || null;
}
/**
* Set and persist the parent session ID for an agent.
* Once set, this association is PERMANENT and never recalculated.
*/
setAgentParentSessionId(agentId, sessionId) {
if (!agentId || !sessionId) return;
// Only set if not already set (first association wins)
if (this.subagentParentMap.has(agentId)) {
return; // Already has a parent, don't override
}
this.subagentParentMap.set(agentId, sessionId);
this.saveSubagentParentMap(); // Persist immediately
// Also update the agent object for consistency
const agent = this.subagents.get(agentId);
if (agent) {
agent.parentSessionId = sessionId;
const session = this.sessions.get(sessionId);
if (session) {
agent.parentSessionName = this.getSessionName(session);
}
this.subagents.set(agentId, agent);
}
}
// ═══════════════════════════════════════════════════════════════
// Help Modal
// ═══════════════════════════════════════════════════════════════
showHelp() {
const modal = document.getElementById('helpModal');
modal.classList.add('active');
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
}
closeHelp() {
document.getElementById('helpModal').classList.remove('active');
// Deactivate focus trap and restore focus
if (this.activeFocusTrap) {
this.activeFocusTrap.deactivate();
this.activeFocusTrap = null;
}
}
closeAllPanels() {
this.closeSessionOptions();
this.closeAppSettings();
this.cancelCloseSession();
this.closeTokenStats();
document.getElementById('monitorPanel').classList.remove('open');
// Collapse subagents panel (don't hide it permanently)
const subagentsPanel = document.getElementById('subagentsPanel');
if (subagentsPanel) {
subagentsPanel.classList.remove('open');
}
this.subagentPanelVisible = false;
}
// ═══════════════════════════════════════════════════════════════
// Token Statistics Modal
// ═══════════════════════════════════════════════════════════════
async openTokenStats() {
try {
const response = await fetch('/api/token-stats');
const data = await response.json();
if (data.success) {
this.renderTokenStats(data);
document.getElementById('tokenStatsModal').classList.add('active');
} else {
this.showToast('Failed to load token stats', 'error');
}
} catch (err) {
console.error('Failed to fetch token stats:', err);
this.showToast('Failed to load token stats', 'error');
}
}
renderTokenStats(data) {
const { daily, totals } = data;
// Calculate period totals
const today = new Date().toISOString().split('T')[0];
const todayData = daily.find(d => d.date === today) || { inputTokens: 0, outputTokens: 0, estimatedCost: 0 };
// Last 7 days totals (for summary card)
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const last7Days = daily.filter(d => new Date(d.date) >= sevenDaysAgo);
const weekInput = last7Days.reduce((sum, d) => sum + d.inputTokens, 0);
const weekOutput = last7Days.reduce((sum, d) => sum + d.outputTokens, 0);
const weekCost = this.estimateCost(weekInput, weekOutput);
// Lifetime totals (from aggregate stats)
const lifetimeInput = totals.totalInputTokens;
const lifetimeOutput = totals.totalOutputTokens;
const lifetimeCost = this.estimateCost(lifetimeInput, lifetimeOutput);
// Render summary cards
const summaryEl = document.getElementById('statsSummary');
summaryEl.innerHTML = `
<div class="stat-card">
<span class="stat-card-label">Today</span>
<span class="stat-card-value">${this.formatTokens(todayData.inputTokens + todayData.outputTokens)}</span>
<span class="stat-card-cost">~$${todayData.estimatedCost.toFixed(2)}</span>
</div>
<div class="stat-card">
<span class="stat-card-label">7 Days</span>
<span class="stat-card-value">${this.formatTokens(weekInput + weekOutput)}</span>
<span class="stat-card-cost">~$${weekCost.toFixed(2)}</span>
</div>
<div class="stat-card">
<span class="stat-card-label">Lifetime</span>
<span class="stat-card-value">${this.formatTokens(lifetimeInput + lifetimeOutput)}</span>
<span class="stat-card-cost">~$${lifetimeCost.toFixed(2)}</span>
</div>
`;
// 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 `<div class="bar" style="height: ${height}%" data-tooltip="${tooltip}"></div>`;
}).join('');
daysEl.innerHTML = chartData.map(d => `<span>${d.dayName}</span>`).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 = '<div class="stats-no-data">No usage data recorded yet</div>';
} else {
tableEl.innerHTML = `
<div class="stats-table-header">
<span>Date</span>
<span>Input</span>
<span>Output</span>
<span>Cost</span>
</div>
${tableData.map(d => {
const dateObj = new Date(d.date + 'T00:00:00');
const dateStr = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return `
<div class="stats-table-row">
<span class="cell cell-date">${dateStr}</span>
<span class="cell">${this.formatTokens(d.inputTokens)}</span>
<span class="cell">${this.formatTokens(d.outputTokens)}</span>
<span class="cell cell-cost">$${d.estimatedCost.toFixed(2)}</span>
</div>
`;
}).join('')}
`;
}
}
closeTokenStats() {
const modal = document.getElementById('tokenStatsModal');
if (modal) {
modal.classList.remove('active');
}
}
// ═══════════════════════════════════════════════════════════════
// Monitor Panel (combined Mux Sessions + Background Tasks)
// ═══════════════════════════════════════════════════════════════
async toggleMonitorPanel() {
const panel = document.getElementById('monitorPanel');
const toggleBtn = document.getElementById('monitorToggleBtn');
panel.classList.toggle('open');
if (panel.classList.contains('open')) {
// Load screens and start stats collection
await this.loadMuxSessions();
await fetch('/api/mux-sessions/stats/start', { method: 'POST' });
this.renderTaskPanel();
if (toggleBtn) toggleBtn.innerHTML = '&#x25BC;'; // Down arrow when open
} else {
// Stop stats collection when panel is closed
await fetch('/api/mux-sessions/stats/stop', { method: 'POST' });
if (toggleBtn) toggleBtn.innerHTML = '&#x25B2;'; // 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 = '&#x29C9;'; // 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 = '&#x229E;'; // Attach icon (squared plus - dock back)
detachBtn.title = 'Attach panel';
}
// Setup drag functionality
this.setupMonitorDrag();
}
}
setupMonitorDrag() {
const panel = document.getElementById('monitorPanel');
const header = document.getElementById('monitorPanelHeader');
if (!panel || !header) return;
let isDragging = false;
let startX, startY, startLeft, startTop;
const onStart = (e) => {
// Only drag from header, not from buttons
if (e.target.closest('button')) return;
if (!panel.classList.contains('detached')) return;
isDragging = true;
const coords = getEventCoords(e);
startX = coords.clientX;
startY = coords.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onEnd);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd);
e.preventDefault();
};
const onMove = (e) => {
if (!isDragging) return;
const coords = getEventCoords(e);
const dx = coords.clientX - startX;
const dy = coords.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// Keep within viewport bounds
const rect = panel.getBoundingClientRect();
newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft));
newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop));
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
};
const onEnd = () => {
isDragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onEnd);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
};
// Remove existing listeners before adding new ones
header.removeEventListener('mousedown', header._dragHandler);
header.removeEventListener('touchstart', header._touchDragHandler);
header._dragHandler = onStart;
header._touchDragHandler = onStart;
header.addEventListener('mousedown', onStart);
header.addEventListener('touchstart', onStart, { passive: false });
}
// ═══════════════════════════════════════════════════════════════
// Subagents Panel Detach & Drag
// ═══════════════════════════════════════════════════════════════
toggleSubagentsDetach() {
const panel = document.getElementById('subagentsPanel');
const detachBtn = document.getElementById('subagentsDetachBtn');
if (panel.classList.contains('detached')) {
// Re-attach to bottom
panel.classList.remove('detached');
panel.style.top = '';
panel.style.left = '';
panel.style.width = '';
panel.style.height = '';
if (detachBtn) {
detachBtn.innerHTML = '&#x29C9;'; // 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 = '&#x229E;'; // Attach icon
detachBtn.title = 'Attach panel';
}
// Setup drag functionality
this.setupSubagentsDrag();
}
}
setupSubagentsDrag() {
const panel = document.getElementById('subagentsPanel');
const header = document.getElementById('subagentsPanelHeader');
if (!panel || !header) return;
let isDragging = false;
let startX, startY, startLeft, startTop;
const onStart = (e) => {
// Only drag from header, not from buttons
if (e.target.closest('button')) return;
if (!panel.classList.contains('detached')) return;
isDragging = true;
const coords = getEventCoords(e);
startX = coords.clientX;
startY = coords.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onEnd);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd);
e.preventDefault();
};
const onMove = (e) => {
if (!isDragging) return;
const coords = getEventCoords(e);
const dx = coords.clientX - startX;
const dy = coords.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// Keep within viewport bounds
const rect = panel.getBoundingClientRect();
newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft));
newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop));
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
};
const onEnd = () => {
isDragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onEnd);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
};
// Remove existing listeners before adding new ones
header.removeEventListener('mousedown', header._dragHandler);
header.removeEventListener('touchstart', header._touchDragHandler);
header._dragHandler = onStart;
header._touchDragHandler = onStart;
header.addEventListener('mousedown', onStart);
header.addEventListener('touchstart', onStart, { passive: false });
}
renderTaskPanel() {
// Debounce renders at 100ms to prevent excessive DOM updates
if (this.renderTaskPanelTimeout) {
clearTimeout(this.renderTaskPanelTimeout);
}
this.renderTaskPanelTimeout = setTimeout(() => {
this._renderTaskPanelImmediate();
}, 100);
}
_renderTaskPanelImmediate() {
const session = this.sessions.get(this.activeSessionId);
const body = document.getElementById('backgroundTasksBody');
const stats = document.getElementById('taskPanelStats');
const section = document.getElementById('backgroundTasksSection');
if (!session || !session.taskTree || session.taskTree.length === 0) {
// Hide the entire section when there are no background tasks
if (section) section.style.display = 'none';
body.innerHTML = '';
stats.textContent = '0 tasks';
return;
}
// Show the section when there are tasks
if (section) section.style.display = '';
const taskStats = session.taskStats || { running: 0, completed: 0, failed: 0, total: 0 };
stats.textContent = `${taskStats.running} running, ${taskStats.completed} done`;
// Render task tree recursively
const renderTask = (task, allTasks) => {
const statusIcon = task.status === 'running' ? '' :
task.status === 'completed' ? '&#x2713;' : '&#x2717;';
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 = '<div class="task-children">';
for (const childId of task.children) {
// Find child task in allTasks map
const childTask = allTasks.find(t => t.id === childId);
if (childTask) {
childrenHtml += `<div class="task-node">${renderTask(childTask, allTasks)}</div>`;
}
}
childrenHtml += '</div>';
}
return `
<div class="task-item">
<span class="task-status-icon ${task.status}">${statusIcon}</span>
<div class="task-info">
<div class="task-description">${escapeHtml(task.description)}</div>
<div class="task-meta">
<span class="task-type">${task.subagentType}</span>
<span>${duration}</span>
</div>
</div>
</div>
${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 = '<div class="task-tree">';
for (const task of session.taskTree) {
html += `<div class="task-node">${renderTask(task, allTasks)}</div>`;
}
html += '</div>';
body.innerHTML = html;
}
flattenTaskTree(tasks, result = []) {
for (const task of tasks) {
result.push(task);
// Children are stored as IDs, not nested objects in taskTree
// The task tree from server already has the structure we need
}
return result;
}
// ═══════════════════════════════════════════════════════════════
// Enhanced Ralph Wiggum Loop Panel
// ═══════════════════════════════════════════════════════════════
updateRalphState(sessionId, updates) {
const existing = this.ralphStates.get(sessionId) || { loop: null, todos: [] };
const updated = { ...existing, ...updates };
this.ralphStates.set(sessionId, updated);
// Re-render if this is the active session
if (sessionId === this.activeSessionId) {
this.renderRalphStatePanel();
}
}
toggleRalphStatePanel() {
// Preserve xterm scroll position to prevent jump when panel height changes
const xtermViewport = this.terminal?.element?.querySelector('.xterm-viewport');
const scrollTop = xtermViewport?.scrollTop;
this.ralphStatePanelCollapsed = !this.ralphStatePanelCollapsed;
this.renderRalphStatePanel();
// Restore scroll position and refit terminal after layout change
requestAnimationFrame(() => {
// Restore xterm scroll position
if (xtermViewport && scrollTop !== undefined) {
xtermViewport.scrollTop = scrollTop;
}
// Refit terminal to new container size
if (this.terminal && this.fitAddon) {
this.fitAddon.fit();
}
});
}
async closeRalphTracker() {
if (!this.activeSessionId) return;
// Mark this session as explicitly closed - will stay hidden until user re-enables
this.ralphClosedSessions.add(this.activeSessionId);
// Disable tracker via API
await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-config`, { enabled: false });
// Clear local state and hide panel
this.ralphStates.delete(this.activeSessionId);
this.renderRalphStatePanel();
}
// ═══════════════════════════════════════════════════════════════
// @fix_plan.md Integration
// ═══════════════════════════════════════════════════════════════
toggleRalphMenu() {
const dropdown = document.getElementById('ralphDropdown');
if (dropdown) {
dropdown.classList.toggle('show');
}
}
closeRalphMenu() {
const dropdown = document.getElementById('ralphDropdown');
if (dropdown) {
dropdown.classList.remove('show');
}
}
async resetCircuitBreaker() {
if (!this.activeSessionId) return;
try {
const response = await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {});
const data = await response?.json();
if (data?.success) {
this.notificationManager?.notify({
urgency: 'info',
category: 'circuit-breaker',
title: 'Reset',
message: 'Circuit breaker reset to CLOSED',
});
}
} catch (error) {
console.error('Error resetting circuit breaker:', error);
}
}
/**
* Generate @fix_plan.md content and show in a modal.
*/
async showFixPlan() {
if (!this.activeSessionId) return;
try {
const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan`);
const data = await response.json();
if (!data.success) {
this.notificationManager?.notify({
urgency: 'error',
category: 'fix-plan',
title: 'Error',
message: data.error || 'Failed to generate fix plan',
});
return;
}
// Show in a modal
this.showFixPlanModal(data.data.content, data.data.todoCount);
} catch (error) {
console.error('Error fetching fix plan:', error);
}
}
/**
* Show fix plan content in a modal.
*/
showFixPlanModal(content, todoCount) {
// Create modal if it doesn't exist
let modal = document.getElementById('fixPlanModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'fixPlanModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content fix-plan-modal">
<div class="modal-header">
<h3>@fix_plan.md</h3>
<button class="btn-close" onclick="app.closeFixPlanModal()">&times;</button>
</div>
<div class="modal-body">
<textarea id="fixPlanContent" class="fix-plan-textarea" readonly></textarea>
</div>
<div class="modal-footer">
<span class="fix-plan-stats" id="fixPlanStats"></span>
<button class="btn btn-secondary" onclick="app.copyFixPlan()">Copy</button>
<button class="btn btn-primary" onclick="app.writeFixPlanToFile()">Write to File</button>
<button class="btn btn-secondary" onclick="app.closeFixPlanModal()">Close</button>
</div>
</div>
`;
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 = '&#x29C9;'; // 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 = '&#x229E;'; // Attach icon (squared plus - dock back)
detachBtn.title = 'Attach panel';
}
// Setup drag functionality
this.setupRalphDrag();
}
this.renderRalphStatePanel();
}
setupRalphDrag() {
const panel = this.$('ralphStatePanel');
const header = this.$('ralphSummary');
if (!panel || !header) return;
let isDragging = false;
let startX, startY, startLeft, startTop;
const onMouseDown = (e) => {
// Only drag from header, not from buttons or toggle
if (e.target.closest('button') || e.target.closest('.ralph-toggle')) return;
if (!panel.classList.contains('detached')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
};
const onMouseMove = (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// Keep within viewport bounds
const rect = panel.getBoundingClientRect();
newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft));
newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop));
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
};
const onMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
// Remove existing listeners before adding new ones
header.removeEventListener('mousedown', header._ralphDragHandler);
header._ralphDragHandler = onMouseDown;
header.addEventListener('mousedown', onMouseDown);
}
renderRalphStatePanel() {
// Debounce renders at 50ms to prevent excessive DOM updates
if (this.renderRalphStatePanelTimeout) {
clearTimeout(this.renderRalphStatePanelTimeout);
}
this.renderRalphStatePanelTimeout = setTimeout(() => {
this._renderRalphStatePanelImmediate();
}, 50);
}
_renderRalphStatePanelImmediate() {
const panel = this.$('ralphStatePanel');
const toggle = this.$('ralphToggle');
if (!panel) return;
// If user explicitly closed this session's Ralph panel, keep it hidden
if (this.ralphClosedSessions.has(this.activeSessionId)) {
panel.style.display = 'none';
return;
}
const state = this.ralphStates.get(this.activeSessionId);
// Check if there's anything to show
// Only show panel if tracker is enabled OR there's active state to display
const isEnabled = state?.loop?.enabled === true;
const hasLoop = state?.loop?.active || state?.loop?.completionPhrase;
const hasTodos = state?.todos?.length > 0;
const hasCircuitBreaker = state?.circuitBreaker && state.circuitBreaker.state !== 'CLOSED';
const hasStatusBlock = state?.statusBlock !== undefined;
if (!isEnabled && !hasLoop && !hasTodos && !hasCircuitBreaker && !hasStatusBlock) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
// Calculate completion percentage
const todos = state?.todos || [];
const completed = todos.filter(t => t.status === 'completed').length;
const total = todos.length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
// Update progress rings
this.updateRalphRing(percent);
// Update status badge (pass completion info)
this.updateRalphStatus(state?.loop, completed, total);
// Update stats
this.updateRalphStats(state?.loop, completed, total);
// Update circuit breaker badge
this.updateCircuitBreakerBadge(state?.circuitBreaker);
// Handle collapsed/expanded state
if (this.ralphStatePanelCollapsed) {
panel.classList.add('collapsed');
if (toggle) toggle.innerHTML = '&#x25BC;'; // Down arrow when collapsed (click to expand)
} else {
panel.classList.remove('collapsed');
if (toggle) toggle.innerHTML = '&#x25B2;'; // 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 = `<span class="cb-icon">⚠</span><span class="cb-text">Warning</span>`;
cbContainer.title = circuitBreaker.reason || 'Circuit breaker warning';
} else if (circuitBreaker.state === 'OPEN') {
cbContainer.classList.add('open');
cbContainer.innerHTML = `<span class="cb-icon">🛑</span><span class="cb-text">Stuck</span>`;
cbContainer.title = circuitBreaker.reason || 'Loop appears stuck';
}
// Add click handler to reset
cbContainer.onclick = () => this.resetCircuitBreaker();
}
updateRalphStats(loop, completed, total) {
// Time stat
const timeEl = this.$('ralphStatTime');
if (timeEl) {
if (loop?.elapsedHours !== null && loop?.elapsedHours !== undefined) {
timeEl.textContent = this.formatRalphTime(loop.elapsedHours);
} else if (loop?.startedAt) {
const hours = (Date.now() - loop.startedAt) / (1000 * 60 * 60);
timeEl.textContent = this.formatRalphTime(hours);
} else {
timeEl.textContent = '0m';
}
}
// Cycles stat
const cyclesEl = this.$('ralphStatCycles');
if (cyclesEl) {
if (loop?.maxIterations) {
cyclesEl.textContent = `${loop.cycleCount || 0}/${loop.maxIterations}`;
} else {
cyclesEl.textContent = String(loop?.cycleCount || 0);
}
}
// Tasks stat
const tasksEl = this.$('ralphStatTasks');
if (tasksEl) {
tasksEl.textContent = `${completed}/${total}`;
}
}
formatRalphTime(hours) {
if (hours < 0.0167) return '0m'; // < 1 minute
if (hours < 1) {
const minutes = Math.round(hours * 60);
return `${minutes}m`;
}
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
updateRalphExpandedView(state) {
// Update phrase
const phraseEl = this.$('ralphPhrase');
if (phraseEl) {
phraseEl.textContent = state?.loop?.completionPhrase || '--';
}
// Update elapsed
const elapsedEl = this.$('ralphElapsed');
if (elapsedEl) {
if (state?.loop?.elapsedHours !== null && state?.loop?.elapsedHours !== undefined) {
elapsedEl.textContent = this.formatRalphTime(state.loop.elapsedHours);
} else if (state?.loop?.startedAt) {
const hours = (Date.now() - state.loop.startedAt) / (1000 * 60 * 60);
elapsedEl.textContent = this.formatRalphTime(hours);
} else {
elapsedEl.textContent = '0m';
}
}
// Update iterations
const iterationsEl = this.$('ralphIterations');
if (iterationsEl) {
if (state?.loop?.maxIterations) {
iterationsEl.textContent = `${state.loop.cycleCount || 0} / ${state.loop.maxIterations}`;
} else {
iterationsEl.textContent = String(state?.loop?.cycleCount || 0);
}
}
// Update tasks count
const todos = state?.todos || [];
const completed = todos.filter(t => t.status === 'completed').length;
const tasksCountEl = this.$('ralphTasksCount');
if (tasksCountEl) {
tasksCountEl.textContent = `${completed}/${todos.length}`;
}
// Update plan version display if available
if (state?.loop?.planVersion) {
this.updatePlanVersionDisplay(state.loop.planVersion, state.loop.planHistoryLength || 1);
} else {
this.updatePlanVersionDisplay(null, 0);
}
// Render task cards
this.renderRalphTasks(todos);
// Render RALPH_STATUS block if present
this.renderRalphStatusBlock(state?.statusBlock);
}
renderRalphStatusBlock(statusBlock) {
// Find or create the status block container
let container = this.$('ralphStatusBlockDisplay');
const expandedContent = this.$('ralphExpandedContent');
if (!statusBlock) {
// Remove container if no status block
if (container) {
container.remove();
}
return;
}
if (!container && expandedContent) {
container = document.createElement('div');
container.id = 'ralphStatusBlockDisplay';
container.className = 'ralph-status-block';
// Insert at the top of expanded content
expandedContent.insertBefore(container, expandedContent.firstChild);
}
if (!container) return;
// Build status class
const statusClass = statusBlock.status === 'IN_PROGRESS' ? 'in-progress'
: statusBlock.status === 'COMPLETE' ? 'complete'
: statusBlock.status === 'BLOCKED' ? 'blocked' : '';
// Build tests status icon
const testsIcon = statusBlock.testsStatus === 'PASSING' ? '✅'
: statusBlock.testsStatus === 'FAILING' ? '❌'
: '⏸';
// Build work type icon
const workIcon = statusBlock.workType === 'IMPLEMENTATION' ? '🔧'
: statusBlock.workType === 'TESTING' ? '🧪'
: statusBlock.workType === 'DOCUMENTATION' ? '📝'
: statusBlock.workType === 'REFACTORING' ? '♻️' : '📋';
let html = `
<div class="ralph-status-block-header">
<span>RALPH_STATUS</span>
<span class="ralph-status-block-status ${statusClass}">${escapeHtml(statusBlock.status)}</span>
${statusBlock.exitSignal ? '<span style="color: #4caf50;">🚪 EXIT</span>' : ''}
</div>
<div class="ralph-status-block-stats">
<span>${workIcon} ${escapeHtml(statusBlock.workType)}</span>
<span>📁 ${statusBlock.filesModified} files</span>
<span>✓ ${escapeHtml(String(statusBlock.tasksCompletedThisLoop))} tasks</span>
<span>${testsIcon} Tests: ${escapeHtml(statusBlock.testsStatus)}</span>
</div>
`;
if (statusBlock.recommendation) {
html += `<div class="ralph-status-block-recommendation">${escapeHtml(statusBlock.recommendation)}</div>`;
}
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 = '<div class="ralph-state-empty">No tasks detected</div>';
}
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 = `
<div class="modal-backdrop" onclick="app.closePlanHistoryModal()"></div>
<div class="modal-content modal-sm">
<div class="modal-header">
<h3>Plan Version History</h3>
<button class="modal-close" onclick="app.closePlanHistoryModal()">&times;</button>
</div>
<div class="modal-body">
<p style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem;">
Current version: <strong>v${currentVersion}</strong>
</p>
<div class="plan-history-list">
${history.map(item => `
<div class="plan-history-item ${item.version === currentVersion ? 'current' : ''}"
onclick="app.rollbackToPlanVersion(${item.version})">
<div>
<span class="plan-history-version">v${item.version}</span>
<span class="plan-history-tasks">${item.taskCount || 0} tasks</span>
</div>
<span class="plan-history-time">${this.formatRelativeTime(item.timestamp)}</span>
</div>
`).join('')}
</div>
</div>
<div class="modal-footer">
<button class="btn-toolbar" onclick="app.closePlanHistoryModal()">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
closePlanHistoryModal() {
const modal = document.getElementById('planHistoryModal');
if (modal) modal.remove();
}
// Rollback to a specific plan version
async rollbackToPlanVersion(version) {
if (!this.activeSessionId) return;
if (!confirm(`Rollback to plan version ${version}? Current changes will be preserved in history.`)) {
return;
}
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/rollback/${version}`, {
method: 'POST'
});
const data = await res.json();
if (data.error) {
this.showToast('Failed to rollback: ' + data.error, 'error');
return;
}
this.showToast(`Rolled back to plan v${version}`, 'success');
this.closePlanHistoryModal();
// Refresh the plan display
this.renderRalphStatePanel();
} catch (err) {
this.showToast('Failed to rollback: ' + err.message, 'error');
}
}
// Format relative time (e.g., "2 mins ago", "1 hour ago")
formatRelativeTime(timestamp) {
if (!timestamp) return '';
const now = Date.now();
const diff = now - timestamp;
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}
// ═══════════════════════════════════════════════════════════════
// Subagent Panel (Claude Code Background Agents)
// ═══════════════════════════════════════════════════════════════
// Legacy alias
toggleSubagentPanel() {
this.toggleSubagentsPanel();
}
updateSubagentBadge() {
const badge = this.$('subagentCountBadge');
const activeCount = Array.from(this.subagents.values()).filter(s => s.status === 'active' || s.status === 'idle').length;
// Update badge with active count
if (badge) {
badge.textContent = activeCount > 0 ? activeCount : '';
}
}
renderSubagentPanel() {
// Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events
if (this._subagentPanelRenderTimeout) {
clearTimeout(this._subagentPanelRenderTimeout);
}
this._subagentPanelRenderTimeout = setTimeout(() => {
scheduleBackground(() => this._renderSubagentPanelImmediate());
}, 150);
}
_renderSubagentPanelImmediate() {
const list = this.$('subagentList');
if (!list) return;
// Always update badge count
this.updateSubagentBadge();
// Always update monitor panel (even if subagent panel is hidden)
this.renderMonitorSubagents();
// If panel is not visible, don't render content
if (!this.subagentPanelVisible) {
return;
}
// Render subagent list
if (this.subagents.size === 0) {
list.innerHTML = '<div class="subagent-empty">No background agents detected</div>';
return;
}
const html = [];
const sorted = Array.from(this.subagents.values()).sort((a, b) => {
// Active first, then by last activity
if (a.status === 'active' && b.status !== 'active') return -1;
if (b.status === 'active' && a.status !== 'active') return 1;
return (b.lastActivityAt || 0) - (a.lastActivityAt || 0);
});
for (const agent of sorted) {
const isActive = this.activeSubagentId === agent.agentId;
const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed';
const activity = this.subagentActivity.get(agent.agentId) || [];
const lastActivity = activity[activity.length - 1];
const lastTool = lastActivity?.type === 'tool' ? lastActivity.tool : null;
const hasWindow = this.subagentWindows.has(agent.agentId);
const canKill = agent.status === 'active' || agent.status === 'idle';
const modelBadge = agent.modelShort
? `<span class="subagent-model-badge ${escapeHtml(agent.modelShort)}">${escapeHtml(agent.modelShort)}</span>`
: '';
const teammateInfo = this.getTeammateInfo(agent);
const displayName = teammateInfo ? teammateInfo.name : (agent.description || agent.agentId.substring(0, 7));
const teammateBadge = this.getTeammateBadgeHtml(agent);
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
html.push(`
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
title="Double-click to open tracking window">
<div class="subagent-header">
${agentIcon}
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)}</span>
${teammateBadge}
${modelBadge}
<span class="subagent-status ${statusClass}">${agent.status}</span>
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
${hasWindow ? '✕' : '⧉'}
</button>
</div>
<div class="subagent-meta">
<span class="subagent-tools">${agent.toolCallCount} tools</span>
${lastTool ? `<span class="subagent-last-tool">${this.getToolIcon(lastTool)} ${lastTool}</span>` : ''}
</div>
</div>
`);
}
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 = '<div class="subagent-empty">Select an agent to view details</div>';
return;
}
const agent = this.subagents.get(this.activeSubagentId);
const activity = this.subagentActivity.get(this.activeSubagentId) || [];
if (!agent) {
detail.innerHTML = '<div class="subagent-empty">Agent not found</div>';
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 `<div class="subagent-activity tool" data-tool-use-id="${a.toolUseId || ''}">
<span class="time">${time}</span>
<span class="icon">${this.getToolIcon(a.tool)}</span>
<span class="name">${a.tool}</span>
<span class="detail">${toolDetail.primary}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`;
} 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 `<div class="subagent-activity tool-result ${statusClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="name">${a.tool || 'result'}</span>
<span class="detail">${escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} 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 `<div class="subagent-activity progress${hookClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="detail">${displayText}</span>
</div>`;
} else if (a.type === 'message') {
const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text;
return `<div class="subagent-activity message">
<span class="time">${time}</span>
<span class="icon">💬</span>
<span class="detail">${escapeHtml(preview)}</span>
</div>`;
}
return '';
}).join('');
const detailTitle = agent.description || `Agent ${agent.agentId}`;
const modelBadge = agent.modelShort
? `<span class="subagent-model-badge ${escapeHtml(agent.modelShort)}">${escapeHtml(agent.modelShort)}</span>`
: '';
const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens)
? `<span>Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑</span>`
: '';
detail.innerHTML = `
<div class="subagent-detail-header">
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
${modelBadge}
<span class="subagent-status ${agent.status}">${agent.status}</span>
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
View Full Transcript
</button>
</div>
<div class="subagent-detail-stats">
<span>Tools: ${agent.toolCallCount}</span>
<span>Entries: ${agent.entryCount}</span>
<span>Size: ${(agent.fileSize / 1024).toFixed(1)}KB</span>
${tokenStats}
</div>
<div class="subagent-activity-log">
${activityHtml || '<div class="subagent-empty">No activity yet</div>'}
</div>
`;
}
toggleToolParams(toolUseId) {
const el = document.getElementById(`tool-params-${toolUseId}`);
if (!el) return;
const btn = el.previousElementSibling;
if (el.style.display === 'none') {
el.style.display = 'block';
if (btn) btn.textContent = '▼';
} else {
el.style.display = 'none';
if (btn) btn.textContent = '▶';
}
}
formatTokenCount(count) {
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
if (count >= 1000) return (count / 1000).toFixed(1) + 'k';
return count.toString();
}
formatBytes(bytes) {
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB';
return bytes + 'B';
}
getToolIcon(tool) {
const icons = {
WebSearch: '🔍',
WebFetch: '🌐',
Read: '📖',
Write: '📝',
Edit: '✏️',
Bash: '💻',
Glob: '📁',
Grep: '🔎',
Task: '🤖',
};
return icons[tool] || '🔧';
}
getToolDetail(tool, input) {
if (!input) return '';
if (tool === 'WebSearch' && input.query) return `"${input.query}"`;
if (tool === 'WebFetch' && input.url) return input.url;
if (tool === 'Read' && input.file_path) return input.file_path;
if ((tool === 'Write' || tool === 'Edit') && input.file_path) return input.file_path;
if (tool === 'Bash' && input.command) {
const cmd = input.command;
return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd;
}
if (tool === 'Glob' && input.pattern) return input.pattern;
if (tool === 'Grep' && input.pattern) return input.pattern;
return '';
}
getToolDetailExpanded(tool, input, fullInput, toolUseId) {
const primary = this.getToolDetail(tool, input);
// Check if there are additional params beyond the primary one
const primaryKeys = ['query', 'url', 'file_path', 'command', 'pattern'];
const inputKeys = Object.keys(fullInput || input || {});
const extraKeys = inputKeys.filter(k => !primaryKeys.includes(k));
const hasMore = extraKeys.length > 0 || (fullInput && JSON.stringify(fullInput).length > 100);
return { primary, hasMore, fullInput: fullInput || input };
}
async killSubagent(agentId) {
try {
const res = await this._apiDelete(`/api/subagents/${agentId}`);
const data = await res?.json();
if (data?.success) {
// Update local state
const agent = this.subagents.get(agentId);
if (agent) {
agent.status = 'completed';
this.subagents.set(agentId, agent);
}
this.renderSubagentPanel();
this.renderSubagentDetail();
this.updateSubagentWindows();
this.showToast(`Subagent ${agentId.substring(0, 7)} killed`, 'success');
} else {
this.showToast(data.error || 'Failed to kill subagent', 'error');
}
} catch (err) {
console.error('Failed to kill subagent:', err);
this.showToast('Failed to kill subagent: ' + err.message, 'error');
}
}
async viewSubagentTranscript(agentId) {
try {
const res = await fetch(`/api/subagents/${agentId}/transcript?format=formatted`);
const data = await res.json();
if (!data.success) {
alert('Failed to load transcript');
return;
}
// Show in a modal or new window
const content = data.data.formatted.join('\n');
const win = window.open('', '_blank', 'width=800,height=600');
win.document.write(`
<html>
<head>
<title>Subagent ${escapeHtml(agentId)} Transcript</title>
<style>
body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 20px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style>
</head>
<body>
<h2>Subagent ${escapeHtml(agentId)} Transcript (${data.data.entryCount} entries)</h2>
<pre>${escapeHtml(content)}</pre>
</body>
</html>
`);
} catch (err) {
console.error('Failed to load transcript:', err);
alert('Failed to load transcript: ' + err.message);
}
}
// ═══════════════════════════════════════════════════════════════
// Subagent Parent TAB Tracking
// ═══════════════════════════════════════════════════════════════
//
// CRITICAL: This system tracks which TAB an agent window connects to.
// The association is stored in `subagentParentMap` (agentId -> sessionId).
// The sessionId IS the tab identifier (tabs have data-id="${sessionId}").
// Once set, this association is PERMANENT and persisted across restarts.
/**
* Find and assign the parent TAB for a subagent.
*
* Matching strategy (in order):
* 1. Use existing stored association from subagentParentMap (permanent)
* 2. Match via claudeSessionId (agent.sessionId === session.claudeSessionId)
* 3. FALLBACK: Use the currently active session (since that's where the user typed the command)
*
* Once found, the association is stored PERMANENTLY in subagentParentMap.
*/
findParentSessionForSubagent(agentId) {
// Check if we already have a permanent association
if (this.subagentParentMap.has(agentId)) {
// Already have a parent - update agent object from stored value
const storedSessionId = this.subagentParentMap.get(agentId);
// Verify the session still exists
if (this.sessions.has(storedSessionId)) {
const agent = this.subagents.get(agentId);
if (agent && !agent.parentSessionId) {
agent.parentSessionId = storedSessionId;
const session = this.sessions.get(storedSessionId);
if (session) {
agent.parentSessionName = this.getSessionName(session);
}
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
}
return;
}
// Stored session no longer exists - clear and re-discover
this.subagentParentMap.delete(agentId);
}
const agent = this.subagents.get(agentId);
if (!agent) return;
// Strategy 1: Match via claudeSessionId (most accurate)
if (agent.sessionId) {
for (const [sessionId, session] of this.sessions) {
if (session.claudeSessionId === agent.sessionId) {
// FOUND! Store this association PERMANENTLY
this.setAgentParentSessionId(agentId, sessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
return;
}
}
}
// Strategy 2: FALLBACK - Use the currently active session
// This works because agents spawn from where the user typed the command
if (this.activeSessionId && this.sessions.has(this.activeSessionId)) {
this.setAgentParentSessionId(agentId, this.activeSessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
return;
}
// Strategy 3: If no active session, use the first session
if (this.sessions.size > 0) {
const firstSessionId = this.sessions.keys().next().value;
this.setAgentParentSessionId(agentId, firstSessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
}
}
/**
* Re-check all orphan subagents (those without a parent TAB) when a session updates.
* Called when session:updated fires with claudeSessionId.
*
* Also re-validates existing associations when claudeSessionId becomes available,
* in case the fallback association was wrong.
*/
recheckOrphanSubagents() {
let anyChanged = false;
for (const [agentId, agent] of this.subagents) {
// Check if this agent has no parent in the persistent map
if (!this.subagentParentMap.has(agentId)) {
this.findParentSessionForSubagent(agentId);
if (this.subagentParentMap.has(agentId)) {
anyChanged = true;
}
} else if (agent.sessionId) {
// Agent has a stored parent, but check if we can now do a proper claudeSessionId match
// This handles the case where fallback was used but now the real parent is known
const storedParent = this.subagentParentMap.get(agentId);
const storedSession = this.sessions.get(storedParent);
// If the stored session doesn't have a matching claudeSessionId, try to find the real match
if (storedSession && storedSession.claudeSessionId !== agent.sessionId) {
for (const [sessionId, session] of this.sessions) {
if (session.claudeSessionId === agent.sessionId) {
// Found the real parent - update the association
this.subagentParentMap.set(agentId, sessionId);
agent.parentSessionId = sessionId;
agent.parentSessionName = this.getSessionName(session);
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
anyChanged = true;
break;
}
}
}
}
}
if (anyChanged) {
this.saveSubagentParentMap();
this.updateConnectionLines();
}
}
/**
* Update parentSessionName for all subagents belonging to a TAB.
* Called when a session is renamed to keep cached names fresh.
*/
updateSubagentParentNames(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
const newName = this.getSessionName(session);
// Skip iteration if name hasn't changed (avoids O(n) loop on every session:updated)
const cachedName = this._parentNameCache?.get(sessionId);
if (cachedName === newName) return;
if (!this._parentNameCache) this._parentNameCache = new Map();
this._parentNameCache.set(sessionId, newName);
for (const [agentId, storedSessionId] of this.subagentParentMap) {
if (storedSessionId === sessionId) {
const agent = this.subagents.get(agentId);
if (agent) {
agent.parentSessionName = newName;
this.subagents.set(agentId, agent);
// Update the window header if open
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
const parentNameEl = windowData.element.querySelector('.subagent-window-parent .parent-name');
if (parentNameEl) {
parentNameEl.textContent = newName;
}
}
}
}
}
}
/**
* Add parent header to an agent window, showing which TAB it belongs to.
*/
updateSubagentWindowParent(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData) return;
// Get parent from persistent map (THE source of truth)
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId) return;
const session = this.sessions.get(parentSessionId);
const parentName = session ? this.getSessionName(session) : 'Unknown';
// Check if parent header already exists
const win = windowData.element;
const existingParent = win.querySelector('.subagent-window-parent');
if (existingParent) {
// Update existing
existingParent.dataset.parentSession = parentSessionId;
const nameEl = existingParent.querySelector('.parent-name');
if (nameEl) {
nameEl.textContent = parentName;
nameEl.onclick = () => this.selectSession(parentSessionId);
}
return;
}
// Insert new parent header after the main header
const header = win.querySelector('.subagent-window-header');
if (header) {
const parentDiv = document.createElement('div');
parentDiv.className = 'subagent-window-parent';
parentDiv.dataset.parentSession = parentSessionId;
parentDiv.innerHTML = `
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
`;
header.insertAdjacentElement('afterend', parentDiv);
}
}
/**
* Show/hide subagent windows based on active session.
* Behavior controlled by "Subagents for Active Tab Only" setting.
* Uses the PERSISTENT subagentParentMap for accurate tab-based visibility.
*/
updateSubagentWindowVisibility() {
const settings = this.loadAppSettingsFromStorage();
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
for (const [agentId, windowInfo] of this.subagentWindows) {
// Get parent from PERSISTENT map (THE source of truth)
const storedParent = this.subagentParentMap.get(agentId);
const agent = this.subagents.get(agentId);
const parentSessionId = storedParent || agent?.parentSessionId;
// Determine visibility based on setting
let shouldShow;
if (activeTabOnly) {
// Show if: no parent known yet, or parent matches active session
const hasKnownParent = !!parentSessionId;
shouldShow = !hasKnownParent || parentSessionId === this.activeSessionId;
} else {
// Show all windows (original behavior)
shouldShow = true;
}
if (shouldShow) {
// Show window (unless it was minimized by user)
if (!windowInfo.minimized) {
windowInfo.element.style.display = 'flex';
// Lazily re-create teammate terminal if it was disposed when hidden
if (windowInfo._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
}
windowInfo.hidden = false;
} else {
// Hide window (but don't close it)
// Dispose teammate terminal to free memory while hidden on inactive tab
this._disposeTeammateTerminalForMinimize(agentId);
windowInfo.element.style.display = 'none';
windowInfo.hidden = true;
}
}
// Update connection lines after visibility changes
this.updateConnectionLines();
// Restack mobile windows after visibility changes
this.relayoutMobileSubagentWindows();
}
// Close all subagent windows for a session (fully removes them, not minimize)
// If cleanupData is true, also remove activity and toolResults data to prevent memory leaks
closeSessionSubagentWindows(sessionId, cleanupData = false) {
const toClose = [];
for (const [agentId, _windowData] of this.subagentWindows) {
const agent = this.subagents.get(agentId);
// Check both subagent parentSessionId and subagentParentMap
// (standalone pane windows use subagentParentMap, not subagents map)
const parentFromMap = this.subagentParentMap.get(agentId);
if (agent?.parentSessionId === sessionId || parentFromMap === sessionId) {
toClose.push(agentId);
}
}
for (const agentId of toClose) {
this.forceCloseSubagentWindow(agentId);
// Clean up activity and tool results data if requested (prevents memory leaks)
if (cleanupData) {
this.subagents.delete(agentId);
this.subagentActivity.delete(agentId);
this.subagentToolResults.delete(agentId);
this.subagentParentMap.delete(agentId);
}
}
// Also clean up minimized agents for this session
this.minimizedSubagents.delete(sessionId);
this.renderSessionTabs();
}
// Fully close a subagent window (removes from DOM, not minimize)
forceCloseSubagentWindow(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
// Clean up resize observer
if (windowData.resizeObserver) {
windowData.resizeObserver.disconnect();
}
// Clean up drag event listeners (both document-level and handle-level)
if (windowData.dragListeners) {
document.removeEventListener('mousemove', windowData.dragListeners.move);
document.removeEventListener('mouseup', windowData.dragListeners.up);
if (windowData.dragListeners.touchMove) {
document.removeEventListener('touchmove', windowData.dragListeners.touchMove);
document.removeEventListener('touchend', windowData.dragListeners.up);
document.removeEventListener('touchcancel', windowData.dragListeners.up);
}
// Remove handle-level listeners before DOM removal
if (windowData.dragListeners.handle) {
windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown);
windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart);
}
}
windowData.element.remove();
this.subagentWindows.delete(agentId);
}
// Clean up teammate terminal if present
const termData = this.teammateTerminals.get(agentId);
if (termData) {
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
}
this.teammateTerminals.delete(agentId);
}
}
minimizeSubagentWindow(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
// Dispose teammate terminal on minimize to free DOM/memory (lazy re-creation on restore)
this._disposeTeammateTerminalForMinimize(agentId);
windowData.element.style.display = 'none';
windowData.minimized = true;
this.updateConnectionLines();
}
}
// Debounced wrapper — coalesces rapid subagent events (tool_call, progress,
// message) into a single DOM update per 100ms per agent window.
scheduleSubagentWindowRender(agentId) {
// Skip DOM updates for windows with lazy (disposed) terminals — they're minimized
const windowData = this.subagentWindows.get(agentId);
if (windowData?.minimized) return;
if (!this._subagentWindowRenderTimeouts) this._subagentWindowRenderTimeouts = new Map();
if (this._subagentWindowRenderTimeouts.has(agentId)) {
clearTimeout(this._subagentWindowRenderTimeouts.get(agentId));
}
this._subagentWindowRenderTimeouts.set(agentId, setTimeout(() => {
this._subagentWindowRenderTimeouts.delete(agentId);
scheduleBackground(() => this.renderSubagentWindowContent(agentId));
}, 100));
}
renderSubagentWindowContent(agentId) {
// Skip if this window has a live terminal (don't overwrite xterm with activity HTML)
if (this.teammateTerminals.has(agentId)) return;
// Skip if this window has a lazy (disposed) terminal — it will be re-created on restore
const windowData = this.subagentWindows.get(agentId);
if (windowData?._lazyTerminal) return;
const body = document.getElementById(`subagent-window-body-${agentId}`);
if (!body) return;
const activity = this.subagentActivity.get(agentId) || [];
if (activity.length === 0) {
body.innerHTML = '<div class="subagent-empty">No activity yet</div>';
return;
}
// Incremental rendering: track how many items are already rendered
const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0;
const maxItems = 100;
const visibleActivity = activity.slice(-maxItems);
// If activity was trimmed or this is a fresh render, do full rebuild
if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 ||
(body.children.length === 1 && body.querySelector('.subagent-empty'))) {
// Full rebuild
const html = visibleActivity.map(a => this._renderActivityItem(a)).join('');
body.innerHTML = html;
body.dataset.renderedCount = String(visibleActivity.length);
} else {
// Incremental: only append new items
const newItems = visibleActivity.slice(renderedCount);
if (newItems.length > 0) {
const newHtml = newItems.map(a => this._renderActivityItem(a)).join('');
body.insertAdjacentHTML('beforeend', newHtml);
body.dataset.renderedCount = String(visibleActivity.length);
// Trim excess children from the front if over maxItems
while (body.children.length > maxItems) {
body.removeChild(body.firstChild);
}
}
}
body.scrollTop = body.scrollHeight;
}
_renderActivityItem(a) {
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
if (a.type === 'tool') {
return `<div class="activity-line">
<span class="time">${time}</span>
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
<span class="tool-name">${a.tool}</span>
<span class="tool-detail">${escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
</div>`;
} 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 `<div class="activity-line result-line${statusClass}">
<span class="time">${time}</span>
<span class="tool-icon">${icon}</span>
<span class="tool-name">${a.tool || '→'}</span>
<span class="tool-detail">${escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} else if (a.type === 'progress') {
const isHook = a.hookEvent || a.hookName;
const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓');
const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType);
return `<div class="activity-line progress-line${isHook ? ' hook-line' : ''}">
<span class="time">${time}</span>
<span class="tool-icon">${icon}</span>
<span class="tool-detail">${escapeHtml(displayText)}</span>
</div>`;
} else if (a.type === 'message') {
const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text;
return `<div class="message-line">
<span class="time">${time}</span> 💬 ${escapeHtml(preview)}
</div>`;
}
return '';
}
// Update all open subagent windows
updateSubagentWindows() {
for (const agentId of this.subagentWindows.keys()) {
this.renderSubagentWindowContent(agentId);
this.updateSubagentWindowHeader(agentId);
}
}
// Update subagent window header (title and status)
updateSubagentWindowHeader(agentId) {
const agent = this.subagents.get(agentId);
if (!agent) return;
const win = document.getElementById(`subagent-window-${agentId}`);
if (!win) return;
// Update title/id element with description if available
const idEl = win.querySelector('.subagent-window-title .id');
if (idEl) {
const teammateInfo = this.getTeammateInfo(agent);
const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7));
const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle;
idEl.textContent = truncatedTitle;
}
// Add or update teammate badge
let tmBadge = win.querySelector('.teammate-badge');
const teammateInfo = this.getTeammateInfo(agent);
if (teammateInfo && !tmBadge) {
const titleContainer = win.querySelector('.subagent-window-title');
if (titleContainer) {
const badge = document.createElement('span');
badge.className = `teammate-badge teammate-color-${teammateInfo.color}`;
badge.title = `Team: ${teammateInfo.teamName}`;
badge.textContent = `@${teammateInfo.name}`;
const statusEl = titleContainer.querySelector('.status');
if (statusEl) statusEl.insertAdjacentElement('beforebegin', badge);
}
}
// Update full tooltip
const titleContainer = win.querySelector('.subagent-window-title');
if (titleContainer) {
titleContainer.title = agent.description || agentId;
}
// Update or add model badge
let modelBadge = win.querySelector('.subagent-window-title .subagent-model-badge');
if (agent.modelShort) {
if (!modelBadge) {
modelBadge = document.createElement('span');
modelBadge.className = `subagent-model-badge ${agent.modelShort}`;
const statusEl = win.querySelector('.subagent-window-title .status');
if (statusEl) {
statusEl.insertAdjacentElement('beforebegin', modelBadge);
}
}
modelBadge.className = `subagent-model-badge ${agent.modelShort}`;
modelBadge.textContent = agent.modelShort;
}
// Update status
const statusEl = win.querySelector('.subagent-window-title .status');
if (statusEl) {
statusEl.className = `status ${agent.status}`;
statusEl.textContent = agent.status;
}
}
// Open windows for all active subagents
openAllActiveSubagentWindows() {
for (const [agentId, agent] of this.subagents) {
if (agent.status === 'active' && !this.subagentWindows.has(agentId)) {
this.openSubagentWindow(agentId);
}
}
}
// ═══════════════════════════════════════════════════════════════
// Agent Teams
// ═══════════════════════════════════════════════════════════════
/** Initialize an xterm.js terminal for a teammate's tmux pane */
initTeammateTerminal(agentId, paneInfo, windowElement) {
const body = windowElement.querySelector('.subagent-window-body');
if (!body) return;
// Clear the activity log content
body.innerHTML = '';
body.classList.add('teammate-terminal-body');
windowElement.classList.add('has-terminal');
const sessionId = paneInfo.sessionId;
// Buffer incoming terminal data until xterm is ready
const pendingData = [];
this.teammateTerminals.set(agentId, {
terminal: null,
fitAddon: null,
paneTarget: paneInfo.paneTarget,
sessionId,
resizeObserver: null,
pendingData,
});
// Defer terminal creation to next frame so the body element has computed dimensions
requestAnimationFrame(() => {
// Safety: if window was closed before we got here, bail out
if (!document.contains(body)) {
this.teammateTerminals.delete(agentId);
return;
}
const terminal = new Terminal({
theme: {
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 5000,
allowTransparency: true,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
terminal.loadAddon(unicode11Addon);
terminal.unicode.activeVersion = '11';
} catch (_e) { /* Unicode11 addon failed */ }
}
try {
terminal.open(body);
} catch (err) {
console.warn('[TeammateTerminal] Failed to open terminal:', err);
this.teammateTerminals.delete(agentId);
return;
}
// Wait for terminal renderer to fully initialize before any writes.
// xterm.js needs a few frames after open() before write() is safe.
setTimeout(() => {
try { fitAddon.fit(); } catch {}
// Fetch initial pane buffer
fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`)
.then(r => r.json())
.then(resp => {
if (resp.success && resp.data?.buffer) {
try { terminal.write(resp.data.buffer); } catch {}
}
})
.catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err));
// Flush any data that arrived while terminal was initializing
for (const chunk of pendingData) {
try { terminal.write(chunk); } catch {}
}
pendingData.length = 0;
}, 100);
// Forward keyboard input to the teammate's pane
terminal.onData((data) => {
fetch(`/api/sessions/${sessionId}/teammate-pane-input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }),
}).catch(err => console.error('[TeammateTerminal] Failed to send input:', err));
});
// Resize observer to refit terminal when window is resized
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} });
});
resizeObserver.observe(body);
// Update the stored entry with the real terminal
const entry = this.teammateTerminals.get(agentId);
if (entry) {
entry.terminal = terminal;
entry.fitAddon = fitAddon;
entry.resizeObserver = resizeObserver;
}
});
}
/** Open a standalone terminal window for a tmux-pane teammate (no subagent entry needed) */
openTeammateTerminalWindow(paneData) {
// Only open if the session has a tab in Codeman
if (!this.sessions.has(paneData.sessionId)) return;
// Use pane target as the unique ID for this window
const windowId = `pane-${paneData.paneTarget}`;
// If window already exists, focus it
if (this.subagentWindows.has(windowId)) {
const existing = this.subagentWindows.get(windowId);
if (existing.hidden) {
existing.element.style.display = 'flex';
existing.hidden = false;
}
existing.element.style.zIndex = ++this.subagentWindowZIndex;
if (existing.minimized) {
this.restoreSubagentWindow(windowId);
}
return;
}
// Calculate position
const windowCount = this.subagentWindows.size;
const windowWidth = 550;
const windowHeight = 400;
const gap = 20;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const startX = 50;
const startY = 120;
const maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1;
const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1;
const col = windowCount % maxCols;
const row = Math.floor(windowCount / maxCols) % maxRows;
let finalX = startX + col * (windowWidth + gap);
let finalY = startY + row * (windowHeight + gap);
finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10));
finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10));
// Color badge
const colorClass = paneData.color || 'blue';
// Create window element
const win = document.createElement('div');
win.className = 'subagent-window has-terminal';
win.id = `subagent-window-${windowId}`;
win.style.zIndex = ++this.subagentWindowZIndex;
win.style.left = `${finalX}px`;
win.style.top = `${finalY}px`;
win.style.width = `${windowWidth}px`;
win.style.height = `${windowHeight}px`;
win.innerHTML = `
<div class="subagent-window-header">
<div class="subagent-window-title" title="Teammate terminal: ${escapeHtml(paneData.teammateName)} (pane ${paneData.paneTarget})">
<span class="icon" style="color: var(--team-color-${colorClass}, #339af0)">⬤</span>
<span class="id">${escapeHtml(paneData.teammateName)}</span>
<span class="status running">terminal</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
</div>
</div>
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
</div>
`;
document.body.appendChild(win);
// Make draggable
const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header'));
// Make resizable if method exists
if (typeof this.makeWindowResizable === 'function') {
this.makeWindowResizable(win);
}
// Check visibility based on active session
const settings = this.loadAppSettingsFromStorage();
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
const shouldHide = activeTabOnly && paneData.sessionId !== this.activeSessionId;
// Store reference
this.subagentWindows.set(windowId, {
element: win,
minimized: false,
hidden: shouldHide,
dragListeners,
description: `Teammate: ${paneData.teammateName}`,
});
// Also add to subagentParentMap for tab-based visibility
this.subagentParentMap.set(windowId, paneData.sessionId);
if (shouldHide) {
win.style.display = 'none';
}
// Focus on click
win.addEventListener('mousedown', () => {
win.style.zIndex = ++this.subagentWindowZIndex;
});
// Resize observer for connection lines
const resizeObserver = new ResizeObserver(() => {
this.updateConnectionLines();
});
resizeObserver.observe(win);
this.subagentWindows.get(windowId).resizeObserver = resizeObserver;
// Init the xterm.js terminal (lazy if hidden)
if (shouldHide) {
// Window starts hidden — defer terminal creation until visible (lazy init)
const windowEntry = this.subagentWindows.get(windowId);
if (windowEntry) {
windowEntry._lazyTerminal = true;
windowEntry._lazyPaneTarget = paneData.paneTarget;
windowEntry._lazySessionId = paneData.sessionId;
}
} else {
this.initTeammateTerminal(windowId, paneData, win);
}
// Animate in
requestAnimationFrame(() => {
win.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
win.style.transform = 'scale(1)';
win.style.opacity = '1';
});
}
/** Rebuild the teammate lookup map from all team configs */
rebuildTeammateMap() {
this.teammateMap.clear();
for (const [teamName, team] of this.teams) {
for (const member of team.members) {
if (member.agentType !== 'team-lead') {
// Use name as key prefix for matching subagent descriptions
this.teammateMap.set(member.name, {
name: member.name,
color: member.color || 'blue',
teamName,
agentId: member.agentId,
});
}
}
}
}
/** Check if a subagent is a teammate and return its info */
getTeammateInfo(agent) {
if (!agent?.description) return null;
// Teammate descriptions start with <teammate-message teammate_id=
const match = agent.description.match(/<teammate-message\s+teammate_id="?([^">\s]+)/);
if (!match) return null;
const teammateId = match[1];
// Extract name from teammate_id (format: name@teamName)
const name = teammateId.split('@')[0];
return this.teammateMap.get(name) || { name, color: 'blue', teamName: 'unknown' };
}
/** Get teammate badge HTML for a subagent */
getTeammateBadgeHtml(agent) {
const info = this.getTeammateInfo(agent);
if (!info) return '';
return `<span class="teammate-badge teammate-color-${info.color}" title="Team: ${escapeHtml(info.teamName)}">@${escapeHtml(info.name)}</span>`;
}
/** Render the team tasks panel */
renderTeamTasksPanel() {
const panel = document.getElementById('teamTasksPanel');
if (!panel) return;
// Find team for active session
let activeTeam = null;
let activeTeamName = null;
if (this.activeSessionId) {
for (const [name, team] of this.teams) {
if (team.leadSessionId === this.activeSessionId) {
activeTeam = team;
activeTeamName = name;
break;
}
}
}
if (!activeTeam) {
panel.style.display = 'none';
return;
}
// Set initial position and make draggable on first show
const wasHidden = panel.style.display === 'none';
panel.style.display = 'flex';
if (wasHidden && !this.teamTasksDragListeners) {
// Position bottom-right
const panelWidth = 360;
const panelHeight = 300;
panel.style.left = `${Math.max(10, window.innerWidth - panelWidth - 20)}px`;
panel.style.top = `${Math.max(10, window.innerHeight - panelHeight - 70)}px`;
// Make draggable
const header = panel.querySelector('.team-tasks-header');
if (header) {
this.teamTasksDragListeners = this.makeWindowDraggable(panel, header);
}
}
const tasks = this.teamTasks.get(activeTeamName) || [];
const completed = tasks.filter(t => t.status === 'completed').length;
const total = tasks.length;
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const headerEl = panel.querySelector('.team-tasks-header-text');
if (headerEl) {
const teammateCount = activeTeam.members.filter(m => m.agentType !== 'team-lead').length;
headerEl.textContent = `Team Tasks (${teammateCount} teammates)`;
}
const progressEl = panel.querySelector('.team-tasks-progress-fill');
if (progressEl) {
progressEl.style.width = `${pct}%`;
}
const progressText = panel.querySelector('.team-tasks-progress-text');
if (progressText) {
progressText.textContent = `${completed}/${total}`;
}
const listEl = panel.querySelector('.team-tasks-list');
if (!listEl) return;
if (tasks.length === 0) {
listEl.innerHTML = '<div class="team-task-empty">No tasks yet</div>';
return;
}
const html = tasks.map(task => {
const statusIcon = task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '◉' : '○';
const statusClass = task.status.replace('_', '-');
const ownerBadge = task.owner
? `<span class="team-task-owner teammate-color-${this.getTeammateColor(task.owner)}">${escapeHtml(task.owner)}</span>`
: '';
return `<div class="team-task-item ${statusClass}">
<span class="team-task-status">${statusIcon}</span>
<span class="team-task-subject">${escapeHtml(task.subject)}</span>
${ownerBadge}
</div>`;
}).join('');
listEl.innerHTML = html;
}
/** Hide team tasks panel and clean up drag listeners */
hideTeamTasksPanel() {
const panel = document.getElementById('teamTasksPanel');
if (panel) panel.style.display = 'none';
if (this.teamTasksDragListeners) {
document.removeEventListener('mousemove', this.teamTasksDragListeners.move);
document.removeEventListener('mouseup', this.teamTasksDragListeners.up);
if (this.teamTasksDragListeners.touchMove) {
document.removeEventListener('touchmove', this.teamTasksDragListeners.touchMove);
document.removeEventListener('touchend', this.teamTasksDragListeners.up);
document.removeEventListener('touchcancel', this.teamTasksDragListeners.up);
}
if (this.teamTasksDragListeners.handle) {
this.teamTasksDragListeners.handle.removeEventListener('mousedown', this.teamTasksDragListeners.handleMouseDown);
this.teamTasksDragListeners.handle.removeEventListener('touchstart', this.teamTasksDragListeners.handleTouchStart);
}
this.teamTasksDragListeners = null;
}
}
/** Get teammate color by name */
getTeammateColor(name) {
const info = this.teammateMap.get(name);
return info?.color || 'blue';
}
// ═══════════════════════════════════════════════════════════════
// Project Insights Panel (Bash Tools with Clickable File Paths)
// ═══════════════════════════════════════════════════════════════
/**
* Normalize a file path to its canonical form for comparison.
* - Expands ~ to home directory approximation
* - Resolves relative paths against working directory (case folder)
* - Normalizes . and .. components
*/
normalizeFilePath(path, workingDir) {
if (!path) return '';
let normalized = path.trim();
const homeDir = '/home/' + (window.USER || 'user'); // Approximation
// Expand ~ to home directory
if (normalized.startsWith('~/')) {
normalized = homeDir + normalized.slice(1);
} else if (normalized === '~') {
normalized = homeDir;
}
// If not absolute, resolve against working directory (case folder)
if (!normalized.startsWith('/') && workingDir) {
normalized = workingDir + '/' + normalized;
}
// Normalize path components (resolve . and ..)
const parts = normalized.split('/');
const stack = [];
for (const part of parts) {
if (part === '' || part === '.') {
continue;
} else if (part === '..') {
if (stack.length > 1) {
stack.pop();
}
} else {
stack.push(part);
}
}
return '/' + stack.join('/');
}
/**
* Extract just the filename from a path.
*/
getFilename(path) {
const parts = path.split('/');
return parts[parts.length - 1] || '';
}
/**
* Check if a path is a "shallow root path" - an absolute path with only one
* component after root (e.g., /test.txt, /file.log).
* These are often typos where the user meant a relative path in the case folder.
*/
isShallowRootPath(path) {
if (!path.startsWith('/')) return false;
const parts = path.split('/').filter(p => p !== '');
return parts.length === 1;
}
/**
* Check if a path is inside (or is) the working directory (case folder).
*/
isPathInWorkingDir(path, workingDir) {
if (!workingDir) return false;
const normalized = this.normalizeFilePath(path, workingDir);
return normalized.startsWith(workingDir + '/') || normalized === workingDir;
}
/**
* Smart path equivalence check.
* Two paths are considered equivalent if:
* 1. They normalize to the same path (standard case)
* 2. One is a "shallow root path" (e.g., /test.txt) and the other is the
* same filename inside the case folder - the shallow root path
* is likely a typo and they probably meant the same file.
*/
pathsAreEquivalent(path1, path2, workingDir) {
const norm1 = this.normalizeFilePath(path1, workingDir);
const norm2 = this.normalizeFilePath(path2, workingDir);
// Standard check: exact normalized match
if (norm1 === norm2) return true;
// Smart check: shallow root path vs case folder path with same filename
const file1 = this.getFilename(norm1);
const file2 = this.getFilename(norm2);
if (file1 !== file2) return false; // Different filenames, can't be equivalent
const shallow1 = this.isShallowRootPath(path1);
const shallow2 = this.isShallowRootPath(path2);
const inWorkDir1 = this.isPathInWorkingDir(norm1, workingDir);
const inWorkDir2 = this.isPathInWorkingDir(norm2, workingDir);
// If one is shallow root (e.g., /test.txt) and other is in case folder
// with same filename, treat as equivalent (user likely made a typo)
if (shallow1 && inWorkDir2) return true;
if (shallow2 && inWorkDir1) return true;
return false;
}
/**
* Pick the "better" of two paths that resolve to the same file.
* Prefers paths inside the case folder, longer/more explicit paths, and absolute paths.
*/
pickBetterPath(path1, path2, workingDir) {
// Prefer paths inside the case folder (working directory)
if (workingDir) {
const inWorkDir1 = this.isPathInWorkingDir(path1, workingDir);
const inWorkDir2 = this.isPathInWorkingDir(path2, workingDir);
if (inWorkDir1 && !inWorkDir2) return path1;
if (inWorkDir2 && !inWorkDir1) return path2;
}
// Prefer absolute paths
const abs1 = path1.startsWith('/');
const abs2 = path2.startsWith('/');
if (abs1 && !abs2) return path1;
if (abs2 && !abs1) return path2;
// Both absolute or both relative - prefer longer (more explicit)
if (path1.length !== path2.length) {
return path1.length > path2.length ? path1 : path2;
}
// Prefer paths without ~
if (!path1.includes('~') && path2.includes('~')) return path1;
if (!path2.includes('~') && path1.includes('~')) return path2;
return path1;
}
/**
* Deduplicate file paths across all tools, keeping the "best" version.
* Uses smart equivalence checking:
* - Standard normalization for relative vs absolute paths
* - Detects likely typos (e.g., /file.txt when caseFolder/file.txt exists)
* - Prefers paths inside the case folder (working directory)
* - Prefers longer, more explicit paths
* Returns a Map of normalized path -> best raw path.
*/
deduplicateProjectInsightPaths(tools, workingDir) {
// Collect all paths with their tool IDs
const allPaths = [];
for (const tool of tools) {
for (const rawPath of tool.filePaths) {
allPaths.push({ rawPath, toolId: tool.id });
}
}
if (allPaths.length <= 1) {
const pathMap = new Map();
for (const p of allPaths) {
pathMap.set(this.normalizeFilePath(p.rawPath, workingDir), p);
}
return pathMap;
}
// Sort paths: prefer paths in case folder first, then by length (longer first)
allPaths.sort((a, b) => {
const aInWorkDir = this.isPathInWorkingDir(a.rawPath, workingDir);
const bInWorkDir = this.isPathInWorkingDir(b.rawPath, workingDir);
if (aInWorkDir && !bInWorkDir) return -1;
if (bInWorkDir && !aInWorkDir) return 1;
return b.rawPath.length - a.rawPath.length; // Longer paths first
});
const result = new Map(); // normalized -> { rawPath, toolId }
const seenNormalized = new Set();
for (const { rawPath, toolId } of allPaths) {
const normalized = this.normalizeFilePath(rawPath, workingDir);
// Check if we've already seen an equivalent path
let isDuplicate = false;
for (const [, existing] of result) {
if (this.pathsAreEquivalent(rawPath, existing.rawPath, workingDir)) {
isDuplicate = true;
break;
}
}
if (!isDuplicate && !seenNormalized.has(normalized)) {
result.set(normalized, { rawPath, toolId });
seenNormalized.add(normalized);
}
}
return result;
}
handleBashToolStart(sessionId, tool) {
let tools = this.projectInsights.get(sessionId) || [];
// Add new tool
tools = tools.filter(t => t.id !== tool.id);
tools.push(tool);
this.projectInsights.set(sessionId, tools);
this.renderProjectInsightsPanel();
}
handleBashToolEnd(sessionId, tool) {
const tools = this.projectInsights.get(sessionId) || [];
const existing = tools.find(t => t.id === tool.id);
if (existing) {
existing.status = 'completed';
}
this.renderProjectInsightsPanel();
// Remove after a short delay
setTimeout(() => {
const current = this.projectInsights.get(sessionId) || [];
this.projectInsights.set(sessionId, current.filter(t => t.id !== tool.id));
this.renderProjectInsightsPanel();
}, 2000);
}
handleBashToolsUpdate(sessionId, tools) {
this.projectInsights.set(sessionId, tools);
this.renderProjectInsightsPanel();
}
renderProjectInsightsPanel() {
const panel = this.$('projectInsightsPanel');
const list = this.$('projectInsightsList');
if (!panel || !list) return;
// Check if panel is enabled in settings
const settings = this.loadAppSettingsFromStorage();
const showProjectInsights = settings.showProjectInsights ?? false;
if (!showProjectInsights) {
panel.classList.remove('visible');
this.projectInsightsPanelVisible = false;
return;
}
// Get tools for active session only
const tools = this.projectInsights.get(this.activeSessionId) || [];
const runningTools = tools.filter(t => t.status === 'running');
if (runningTools.length === 0) {
panel.classList.remove('visible');
this.projectInsightsPanelVisible = false;
return;
}
panel.classList.add('visible');
this.projectInsightsPanelVisible = true;
// Get working directory for path normalization
const session = this.sessions.get(this.activeSessionId);
const workingDir = session?.workingDir || this.currentSessionWorkingDir;
// Smart deduplication: collect all unique paths across all tools
// Paths that resolve to the same file are deduplicated, keeping the most complete version
const deduplicatedPaths = this.deduplicateProjectInsightPaths(runningTools, workingDir);
// Build a set of paths to show (only the best version of each unique file)
const pathsToShow = new Set(Array.from(deduplicatedPaths.values()).map(p => p.rawPath));
const html = [];
for (const tool of runningTools) {
// Filter this tool's paths to only include those that weren't deduplicated away
const filteredPaths = tool.filePaths.filter(p => pathsToShow.has(p));
// Skip tools with no paths to show (all were duplicates of better paths elsewhere)
if (filteredPaths.length === 0) continue;
const cmdDisplay = tool.command.length > 50
? tool.command.substring(0, 50) + '...'
: tool.command;
html.push(`
<div class="project-insight-item" data-tool-id="${tool.id}">
<div class="project-insight-command">
<span class="icon">💻</span>
<span class="cmd" title="${escapeHtml(tool.command)}">${escapeHtml(cmdDisplay)}</span>
<span class="project-insight-status ${tool.status}">${tool.status}</span>
${tool.timeout ? `<span class="project-insight-timeout">${escapeHtml(tool.timeout)}</span>` : ''}
</div>
<div class="project-insight-paths">
`);
for (const path of filteredPaths) {
const fileName = path.split('/').pop();
html.push(`
<span class="project-insight-filepath"
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
`);
}
html.push(`
</div>
</div>
`);
}
list.innerHTML = html.join('');
}
closeProjectInsightsPanel() {
const panel = this.$('projectInsightsPanel');
if (panel) {
panel.classList.remove('visible');
this.projectInsightsPanelVisible = false;
}
}
// ═══════════════════════════════════════════════════════════════
// File Browser Panel
// ═══════════════════════════════════════════════════════════════
// File tree data and state
fileBrowserData = null;
fileBrowserExpandedDirs = new Set();
fileBrowserFilter = '';
fileBrowserAllExpanded = false;
fileBrowserDragListeners = null;
filePreviewContent = '';
async loadFileBrowser(sessionId) {
if (!sessionId) return;
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
if (!treeEl) return;
// Show loading state
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
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 = `<div class="file-browser-empty">Failed to load files: ${escapeHtml(err.message)}</div>`;
}
}
renderFileBrowserTree() {
const treeEl = this.$('fileBrowserTree');
if (!treeEl || !this.fileBrowserData) return;
const { tree } = this.fileBrowserData;
if (!tree || tree.length === 0) {
treeEl.innerHTML = '<div class="file-browser-empty">No files found</div>';
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
? `<span class="file-tree-expand${isExpanded ? ' expanded' : ''}">\u25B6</span>`
: '<span class="file-tree-expand"></span>';
const sizeStr = !isDir && node.size !== undefined
? `<span class="file-tree-size">${this.formatFileSize(node.size)}</span>`
: '';
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
html.push(`
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
${expandIcon}
<span class="file-tree-icon">${icon}</span>
<span class="${nameClass}">${escapeHtml(node.name)}</span>
${sizeStr}
</div>
`);
// Render children if directory is expanded
if (isDir && isExpanded && node.children) {
for (const child of node.children) {
renderNode(child, depth + 1);
}
}
};
for (const node of tree) {
renderNode(node, 0);
}
treeEl.innerHTML = html.join('');
// Add click handlers
treeEl.querySelectorAll('.file-tree-item').forEach(item => {
item.addEventListener('click', () => {
const path = item.dataset.path;
const type = item.dataset.type;
if (type === 'directory') {
this.toggleFileBrowserFolder(path);
} else {
this.openFilePreview(path);
}
});
});
}
hasMatchingChild(node, filter) {
if (!node.children) return false;
for (const child of node.children) {
if (child.name.toLowerCase().includes(filter)) return true;
if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true;
}
return false;
}
toggleFileBrowserFolder(path) {
if (this.fileBrowserExpandedDirs.has(path)) {
this.fileBrowserExpandedDirs.delete(path);
} else {
this.fileBrowserExpandedDirs.add(path);
}
this.renderFileBrowserTree();
}
filterFileBrowser(value) {
this.fileBrowserFilter = value;
// Auto-expand all if filtering
if (value) {
this.expandAllDirectories(this.fileBrowserData?.tree || []);
}
this.renderFileBrowserTree();
}
expandAllDirectories(nodes) {
for (const node of nodes) {
if (node.type === 'directory') {
this.fileBrowserExpandedDirs.add(node.path);
if (node.children) {
this.expandAllDirectories(node.children);
}
}
}
}
collapseAllDirectories() {
this.fileBrowserExpandedDirs.clear();
}
toggleFileBrowserExpand() {
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
const btn = this.$('fileBrowserExpandBtn');
if (this.fileBrowserAllExpanded) {
this.expandAllDirectories(this.fileBrowserData?.tree || []);
if (btn) btn.innerHTML = '\u229F'; // Collapse icon
} else {
this.collapseAllDirectories();
if (btn) btn.innerHTML = '\u229E'; // Expand icon
}
this.renderFileBrowserTree();
}
refreshFileBrowser() {
if (this.activeSessionId) {
this.fileBrowserExpandedDirs.clear();
this.fileBrowserFilter = '';
this.fileBrowserAllExpanded = false;
const searchInput = this.$('fileBrowserSearch');
if (searchInput) searchInput.value = '';
this.loadFileBrowser(this.activeSessionId);
}
}
closeFileBrowserPanel() {
const panel = this.$('fileBrowserPanel');
if (panel) {
panel.classList.remove('visible');
// Reset position so it reopens at default location
panel.style.left = '';
panel.style.top = '';
panel.style.bottom = '';
panel.style.right = '';
}
// Clean up drag listeners
if (this.fileBrowserDragListeners) {
const dl = this.fileBrowserDragListeners;
document.removeEventListener('mousemove', dl.move);
document.removeEventListener('mouseup', dl.up);
document.removeEventListener('touchmove', dl.touchMove);
document.removeEventListener('touchend', dl.up);
document.removeEventListener('touchcancel', dl.up);
if (dl.handle) {
dl.handle.removeEventListener('mousedown', dl.handleMouseDown);
dl.handle.removeEventListener('touchstart', dl.handleTouchStart);
if (dl._onFirstDrag) {
dl.handle.removeEventListener('mousedown', dl._onFirstDrag);
dl.handle.removeEventListener('touchstart', dl._onFirstDrag);
}
}
this.fileBrowserDragListeners = null;
}
// Save setting
const settings = this.loadAppSettingsFromStorage();
settings.showFileBrowser = false;
this.saveAppSettingsToStorage(settings);
}
async openFilePreview(filePath) {
if (!this.activeSessionId || !filePath) return;
const overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle');
const bodyEl = this.$('filePreviewBody');
const footerEl = this.$('filePreviewFooter');
if (!overlay || !bodyEl) return;
// Show overlay with loading state
overlay.classList.add('visible');
titleEl.textContent = filePath;
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
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 = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'video') {
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview</div>`;
footerEl.textContent = data.extension || 'binary';
} else {
// Text content
this.filePreviewContent = data.content;
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
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 = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
}
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 = `
<div class="log-viewer-window-header">
<div class="log-viewer-window-title" title="${escapeHtml(filePath)}">
<span class="icon">📄</span>
<span class="filename">${escapeHtml(fileName)}</span>
<span class="status streaming">streaming</span>
</div>
<div class="log-viewer-window-actions">
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
</div>
</div>
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
<div class="log-info">Connecting to ${escapeHtml(filePath)}...</div>
</div>
`;
document.body.appendChild(win);
// Make draggable (returns listener refs for cleanup)
const dragListeners = this.makeWindowDraggable(win, win.querySelector('.log-viewer-window-header'));
// Connect to SSE stream
const eventSource = new EventSource(
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
);
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
const body = document.getElementById(`log-viewer-body-${windowId}`);
if (!body) return;
switch (data.type) {
case 'connected':
body.innerHTML = '';
break;
case 'data':
// Append data, auto-scroll
const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 10;
const content = escapeHtml(data.content);
body.innerHTML += content;
if (wasAtBottom) {
body.scrollTop = body.scrollHeight;
}
// Trim if too large
if (body.innerHTML.length > 500000) {
body.innerHTML = body.innerHTML.slice(-400000);
}
break;
case 'end':
this.updateLogViewerStatus(windowId, 'disconnected', 'ended');
break;
case 'error':
body.innerHTML += `<div class="log-error">${escapeHtml(data.error)}</div>`;
this.updateLogViewerStatus(windowId, 'error', 'error');
break;
}
};
eventSource.onerror = () => {
this.updateLogViewerStatus(windowId, 'disconnected', 'connection error');
};
// Store reference (including drag listeners for cleanup)
this.logViewerWindows.set(windowId, {
element: win,
eventSource,
filePath,
sessionId,
dragListeners, // Store for cleanup to prevent memory leaks
});
}
updateLogViewerStatus(windowId, statusClass, statusText) {
const statusEl = document.querySelector(`#log-viewer-window-${windowId} .status`);
if (statusEl) {
statusEl.className = `status ${statusClass}`;
statusEl.textContent = statusText;
}
}
closeLogViewerWindow(windowId) {
const windowData = this.logViewerWindows.get(windowId);
if (!windowData) return;
// Close SSE connection
if (windowData.eventSource) {
windowData.eventSource.close();
}
// Clean up drag event listeners (both document-level and handle-level)
if (windowData.dragListeners) {
document.removeEventListener('mousemove', windowData.dragListeners.move);
document.removeEventListener('mouseup', windowData.dragListeners.up);
if (windowData.dragListeners.handle) {
windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown);
windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart);
}
}
// Remove element
windowData.element.remove();
// Remove from map
this.logViewerWindows.delete(windowId);
}
// Close all log viewer windows for a session
closeSessionLogViewerWindows(sessionId) {
const toClose = [];
for (const [windowId, data] of this.logViewerWindows) {
if (data.sessionId === sessionId) {
toClose.push(windowId);
}
}
for (const windowId of toClose) {
this.closeLogViewerWindow(windowId);
}
}
// ═══════════════════════════════════════════════════════════════
// Image Popup Windows (Auto-popup for Screenshots)
// ═══════════════════════════════════════════════════════════════
/**
* Open a popup window to display a detected image.
* Called automatically when image:detected SSE event is received.
*/
openImagePopup(imageEvent) {
const { sessionId, filePath, relativePath, fileName, timestamp, size } = imageEvent;
// Create unique window ID
const imageId = `${sessionId}-${timestamp}`;
// If window already exists for this image, focus it
if (this.imagePopups.has(imageId)) {
const existing = this.imagePopups.get(imageId);
existing.element.style.zIndex = ++this.imagePopupZIndex;
return;
}
// Cap open popups at 20 — close oldest when at limit
const MAX_IMAGE_POPUPS = 20;
if (this.imagePopups.size >= MAX_IMAGE_POPUPS) {
// Map iteration order is insertion order, so first key is oldest
const oldestId = this.imagePopups.keys().next().value;
if (oldestId) this.closeImagePopup(oldestId);
}
// Calculate position (cascade from center, with offset for multiple popups)
const windowCount = this.imagePopups.size;
const centerX = (window.innerWidth - 600) / 2;
const centerY = (window.innerHeight - 500) / 2;
const offsetX = centerX + (windowCount % 5) * 30;
const offsetY = centerY + (windowCount % 5) * 30;
// Get session name for display
const session = this.sessions.get(sessionId);
const sessionName = session?.name || sessionId.substring(0, 8);
// Format file size
const sizeKB = (size / 1024).toFixed(1);
// Build image URL using the existing file-raw endpoint
// Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images
const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`;
// Create window element
const win = document.createElement('div');
win.className = 'image-popup-window';
win.id = `image-popup-${imageId}`;
win.style.left = `${offsetX}px`;
win.style.top = `${offsetY}px`;
win.style.zIndex = ++this.imagePopupZIndex;
win.innerHTML = `
<div class="image-popup-header">
<div class="image-popup-title" title="${escapeHtml(filePath)}">
<span class="icon">🖼️</span>
<span class="filename">${escapeHtml(fileName)}</span>
<span class="session-badge">${escapeHtml(sessionName)}</span>
<span class="size-badge">${sizeKB} KB</span>
</div>
<div class="image-popup-actions">
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
</div>
</div>
<div class="image-popup-body">
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
</div>
`;
document.body.appendChild(win);
// Make draggable
const dragListeners = this.makeWindowDraggable(win, win.querySelector('.image-popup-header'));
// Focus on click
win.addEventListener('mousedown', () => {
win.style.zIndex = ++this.imagePopupZIndex;
});
// Store reference
this.imagePopups.set(imageId, {
element: win,
sessionId,
filePath,
dragListeners,
});
}
/**
* Close an image popup window.
*/
closeImagePopup(imageId) {
const popupData = this.imagePopups.get(imageId);
if (!popupData) return;
// Clean up drag event listeners (both document-level and handle-level)
if (popupData.dragListeners) {
document.removeEventListener('mousemove', popupData.dragListeners.move);
document.removeEventListener('mouseup', popupData.dragListeners.up);
if (popupData.dragListeners.touchMove) {
document.removeEventListener('touchmove', popupData.dragListeners.touchMove);
document.removeEventListener('touchend', popupData.dragListeners.up);
document.removeEventListener('touchcancel', popupData.dragListeners.up);
}
if (popupData.dragListeners.handle) {
popupData.dragListeners.handle.removeEventListener('mousedown', popupData.dragListeners.handleMouseDown);
popupData.dragListeners.handle.removeEventListener('touchstart', popupData.dragListeners.handleTouchStart);
}
}
// Remove element
popupData.element.remove();
// Remove from map
this.imagePopups.delete(imageId);
}
/**
* Open image in a new browser tab.
*/
openImageInNewTab(url) {
window.open(url, '_blank');
}
/**
* Close all image popups for a session.
*/
closeSessionImagePopups(sessionId) {
const toClose = [];
for (const [imageId, data] of this.imagePopups) {
if (data.sessionId === sessionId) {
toClose.push(imageId);
}
}
for (const imageId of toClose) {
this.closeImagePopup(imageId);
}
}
// ═══════════════════════════════════════════════════════════════
// Mux Sessions (in Monitor Panel)
// ═══════════════════════════════════════════════════════════════
async loadMuxSessions() {
try {
const res = await fetch('/api/mux-sessions');
const data = await res.json();
this.muxSessions = data.sessions || [];
this.renderMuxSessions();
} catch (err) {
console.error('Failed to load mux sessions:', err);
}
}
killAllMuxSessions() {
const count = this.muxSessions?.length || 0;
if (count === 0) {
alert('No sessions to kill');
return;
}
// Show the kill all modal
document.getElementById('killAllCount').textContent = count;
const modal = document.getElementById('killAllModal');
modal.classList.add('active');
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
}
closeKillAllModal() {
document.getElementById('killAllModal').classList.remove('active');
// Deactivate focus trap and restore focus
if (this.activeFocusTrap) {
this.activeFocusTrap.deactivate();
this.activeFocusTrap = null;
}
}
async confirmKillAll(killMux) {
this.closeKillAllModal();
try {
if (killMux) {
// Kill everything including tmux sessions
const res = await fetch('/api/sessions', { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.sessions.clear();
this.muxSessions = [];
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.renderSessionTabs();
this.renderMuxSessions();
this.terminal.clear();
this.terminal.reset();
this.toast('All sessions and tmux killed', 'success');
}
} else {
// Just remove tabs, keep mux sessions running
this.sessions.clear();
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.renderSessionTabs();
this.terminal.clear();
this.terminal.reset();
this.toast('All tabs removed, tmux still running', 'info');
}
} catch (err) {
console.error('Failed to kill sessions:', err);
this.toast('Failed to kill sessions: ' + err.message, 'error');
}
}
// ═══════════════════════════════════════════════════════════════
// Case Settings
// ═══════════════════════════════════════════════════════════════
toggleCaseSettings() {
const popover = document.getElementById('caseSettingsPopover');
if (popover.classList.contains('hidden')) {
// Load settings for current case
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
popover.classList.remove('hidden');
// Close on outside click (one-shot listener)
const closeHandler = (e) => {
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) {
popover.classList.add('hidden');
document.removeEventListener('click', closeHandler);
}
};
// Defer to avoid catching the current click
setTimeout(() => document.addEventListener('click', closeHandler), 0);
} else {
popover.classList.add('hidden');
}
}
getCaseSettings(caseName) {
try {
const stored = localStorage.getItem('caseSettings_' + caseName);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return { agentTeams: false };
}
saveCaseSettings(caseName, settings) {
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
}
onCaseSettingChanged() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
this.saveCaseSettings(caseName, settings);
// Sync mobile checkbox
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
}
toggleCaseSettingsMobile() {
const popover = document.getElementById('caseSettingsPopoverMobile');
if (popover.classList.contains('hidden')) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
popover.classList.remove('hidden');
const closeHandler = (e) => {
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) {
popover.classList.add('hidden');
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
} else {
popover.classList.add('hidden');
}
}
onCaseSettingChangedMobile() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName);
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
this.saveCaseSettings(caseName, settings);
// Sync desktop checkbox
const desktopCheckbox = document.getElementById('caseAgentTeams');
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
}
// ═══════════════════════════════════════════════════════════════
// Create Case Modal
// ═══════════════════════════════════════════════════════════════
showCreateCaseModal() {
document.getElementById('newCaseName').value = '';
document.getElementById('newCaseDescription').value = '';
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
// Wire up tab buttons
const modal = document.getElementById('createCaseModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
});
// Scroll-into-view on focus for mobile keyboard visibility
modal.querySelectorAll('input[type="text"]').forEach(input => {
if (!input._mobileScrollWired) {
input._mobileScrollWired = true;
input.addEventListener('focus', () => {
if (window.innerWidth <= 430) {
setTimeout(() => input.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
}
});
}
});
modal.classList.add('active');
document.getElementById('newCaseName').focus();
}
switchCaseModalTab(tabName) {
this.caseModalTab = tabName;
const modal = document.getElementById('createCaseModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
// Update submit button text
const submitBtn = document.getElementById('caseModalSubmit');
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
// Focus appropriate input
if (tabName === 'case-create') {
document.getElementById('newCaseName').focus();
} else {
document.getElementById('linkCaseName').focus();
}
}
closeCreateCaseModal() {
document.getElementById('createCaseModal').classList.remove('active');
}
async submitCaseModal() {
const btn = document.getElementById('caseModalSubmit');
const originalText = btn.textContent;
btn.classList.add('loading');
btn.textContent = this.caseModalTab === 'case-create' ? 'Creating...' : 'Linking...';
try {
if (this.caseModalTab === 'case-create') {
await this.createCase();
} else {
await this.linkCase();
}
} finally {
btn.classList.remove('loading');
btn.textContent = originalText;
}
}
async createCase() {
const name = document.getElementById('newCaseName').value.trim();
const description = document.getElementById('newCaseDescription').value.trim();
if (!name) {
this.showToast('Please enter a case name', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
try {
const res = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
});
const data = await res.json();
if (data.success) {
this.closeCreateCaseModal();
this.showToast(`Case "${name}" created`, 'success');
// Reload cases and select the new one
await this.loadQuickStartCases(name);
// Save as last used case
await this.saveLastUsedCase(name);
} else {
this.showToast(data.error || 'Failed to create case', 'error');
}
} catch (err) {
console.error('Failed to create case:', err);
this.showToast('Failed to create case: ' + err.message, 'error');
}
}
async linkCase() {
const name = document.getElementById('linkCaseName').value.trim();
const path = document.getElementById('linkCasePath').value.trim();
if (!name) {
this.showToast('Please enter a case name', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!path) {
this.showToast('Please enter a folder path', 'error');
return;
}
try {
const res = await fetch('/api/cases/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, path })
});
const data = await res.json();
if (data.success) {
this.closeCreateCaseModal();
this.showToast(`Case "${name}" linked to ${path}`, 'success');
// Reload cases and select the new one
await this.loadQuickStartCases(name);
// Save as last used case
await this.saveLastUsedCase(name);
} else {
this.showToast(data.error || 'Failed to link case', 'error');
}
} catch (err) {
console.error('Failed to link case:', err);
this.showToast('Failed to link case: ' + err.message, 'error');
}
}
// ═══════════════════════════════════════════════════════════════
// Mobile Case Picker
// ═══════════════════════════════════════════════════════════════
showMobileCasePicker() {
const modal = document.getElementById('mobileCasePickerModal');
const listContainer = document.getElementById('mobileCaseList');
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
// Build case list HTML
let html = '';
const cases = this.cases || [];
// Add testcase if not in list
const hasTestcase = cases.some(c => c.name === 'testcase');
const allCases = hasTestcase ? cases : [{ name: 'testcase' }, ...cases];
for (const c of allCases) {
const isSelected = c.name === currentCase;
html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
onclick="app.selectMobileCase('${escapeHtml(c.name)}')">
<span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-check">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
</button>
`;
}
listContainer.innerHTML = html;
modal.classList.add('active');
}
closeMobileCasePicker() {
document.getElementById('mobileCasePickerModal').classList.remove('active');
}
selectMobileCase(caseName) {
// Update the desktop select (source of truth)
const select = document.getElementById('quickStartCase');
select.value = caseName;
// Update mobile button label
this.updateMobileCaseLabel(caseName);
// Update directory display
this.updateDirDisplayForCase(caseName);
// Save as last used
this.saveLastUsedCase(caseName);
// Close the picker
this.closeMobileCasePicker();
this.showToast(`Selected: ${caseName}`, 'success');
}
updateMobileCaseLabel(caseName) {
const label = document.getElementById('mobileCaseName');
if (label) {
// Let CSS handle truncation via text-overflow: ellipsis
label.textContent = caseName;
}
}
showCreateCaseFromMobile() {
// Close mobile picker first
this.closeMobileCasePicker();
// Open the create case modal with slide-up animation
this.showCreateCaseModal();
const modal = document.getElementById('createCaseModal');
modal.classList.add('from-mobile');
// Remove animation class after it plays
setTimeout(() => modal.classList.remove('from-mobile'), 300);
}
renderMuxSessions() {
// Debounce renders at 100ms to prevent excessive DOM updates
if (this.renderMuxSessionsTimeout) {
clearTimeout(this.renderMuxSessionsTimeout);
}
this.renderMuxSessionsTimeout = setTimeout(() => {
this._renderMuxSessionsImmediate();
}, 100);
}
_renderMuxSessionsImmediate() {
const body = document.getElementById('muxSessionsBody');
if (!this.muxSessions || this.muxSessions.length === 0) {
body.innerHTML = '<div class="monitor-empty">No mux sessions</div>';
return;
}
let html = '';
for (const muxSession of this.muxSessions) {
const stats = muxSession.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 };
// Look up rich session data by sessionId
const session = this.sessions.get(muxSession.sessionId);
const status = session ? session.status : 'unknown';
const isWorking = session ? session.isWorking : false;
// Status badge
let statusLabel, statusClass;
if (status === 'idle' && !isWorking) {
statusLabel = 'IDLE';
statusClass = 'status-idle';
} else if (status === 'busy' || isWorking) {
statusLabel = 'WORKING';
statusClass = 'status-working';
} else if (status === 'stopped') {
statusLabel = 'STOPPED';
statusClass = 'status-stopped';
} else {
statusLabel = status.toUpperCase();
statusClass = '';
}
// Token and cost info
const tokens = session && session.tokens ? session.tokens : null;
const totalCost = session ? session.totalCost : 0;
const model = session ? (session.cliModel || '') : '';
const modelShort = model.includes('opus') ? 'opus' : model.includes('sonnet') ? 'sonnet' : model.includes('haiku') ? 'haiku' : '';
// Ralph/Todo progress
const todoStats = session ? session.ralphTodoStats : null;
let todoHtml = '';
if (todoStats && todoStats.total > 0) {
const pct = Math.round((todoStats.completed / todoStats.total) * 100);
todoHtml = `<span class="process-stat todo-progress">${todoStats.completed}/${todoStats.total} (${pct}%)</span>`;
}
// Format tokens
let tokenHtml = '';
if (tokens && tokens.total > 0) {
const totalK = (tokens.total / 1000).toFixed(1);
tokenHtml = `<span class="process-stat tokens">${totalK}k tok</span>`;
}
// Format cost
let costHtml = '';
if (totalCost > 0) {
costHtml = `<span class="process-stat cost">$${totalCost.toFixed(2)}</span>`;
}
// Model badge
let modelHtml = '';
if (modelShort) {
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
}
html += `
<div class="process-item">
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
<div class="process-info">
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
<div class="process-meta">
${tokenHtml}
${costHtml}
${todoHtml}
<span class="process-stat memory">${stats.memoryMB}MB</span>
<span class="process-stat cpu">${stats.cpuPercent}%</span>
</div>
</div>
<div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killMuxSession('${escapeHtml(muxSession.sessionId)}')" title="Kill session">Kill</button>
</div>
</div>
`;
}
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 = '<div class="monitor-empty">No background agents</div>';
return;
}
let html = '';
for (const agent of subagents) {
const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed';
const modelBadge = agent.modelShort ? `<span class="model-badge ${agent.modelShort}">${agent.modelShort}</span>` : '';
const desc = agent.description ? escapeHtml(agent.description.substring(0, 40)) : agent.agentId;
html += `
<div class="process-item">
<span class="process-mode ${statusClass}">${agent.status}</span>
<div class="process-info">
<div class="process-name">${modelBadge} ${desc}</div>
<div class="process-meta">
<span>ID: ${agent.agentId}</span>
<span>${agent.toolCallCount || 0} tools</span>
</div>
</div>
<div class="process-actions">
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
</div>
</div>
`;
}
body.innerHTML = html;
}
async killMuxSession(sessionId) {
if (!confirm('Kill this mux session?')) return;
try {
// Use closeSession to properly clean up both the session tab and tmux process
// (closeSession handles its own toast messaging)
await this.closeSession(sessionId, true);
} catch (err) {
// Fallback: kill mux directly if session cleanup fails
try { await fetch(`/api/mux-sessions/${sessionId}`, { method: 'DELETE' }); } catch (_ignored) {}
this.showToast('Tmux session killed', 'success');
}
this.muxSessions = this.muxSessions.filter(s => s.sessionId !== sessionId);
this.renderMuxSessions();
}
async reconcileMuxSessions() {
try {
const res = await fetch('/api/mux-sessions/reconcile', { method: 'POST' });
const data = await res.json();
if (data.dead && data.dead.length > 0) {
this.showToast(`Found ${data.dead.length} dead mux session(s)`, 'warning');
await this.loadMuxSessions();
} else {
this.showToast('All mux sessions are alive', 'success');
}
} catch (err) {
this.showToast('Failed to reconcile mux sessions', 'error');
}
}
// ═══════════════════════════════════════════════════════════════
// Plan Wizard Agents in Monitor
// ═══════════════════════════════════════════════════════════════
renderMonitorPlanAgents() {
const section = document.getElementById('monitorPlanAgentsSection');
const body = document.getElementById('monitorPlanAgentsBody');
const stats = document.getElementById('monitorPlanAgentStats');
if (!section || !body) return;
const planAgents = Array.from(this.planSubagents?.values() || []);
const hasActiveOrchestrator = !!this.activePlanOrchestratorId;
// Show section only if there are plan agents or active orchestrator
if (planAgents.length === 0 && !hasActiveOrchestrator) {
section.style.display = 'none';
return;
}
section.style.display = '';
const activeCount = planAgents.filter(a => a.status === 'running').length;
const completedCount = planAgents.filter(a => a.status === 'completed' || a.status === 'failed').length;
if (stats) {
if (hasActiveOrchestrator) {
stats.textContent = `${activeCount} running, ${completedCount} done`;
} else {
stats.textContent = `${planAgents.length} total`;
}
}
if (planAgents.length === 0) {
body.innerHTML = `<div class="monitor-empty">${hasActiveOrchestrator ? 'Plan generation starting...' : 'No plan agents'}</div>`;
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 ? `<span class="model-badge opus">opus</span>` : '';
const detail = agent.detail ? escapeHtml(agent.detail.substring(0, 50)) : '';
const duration = agent.durationMs ? `${(agent.durationMs / 1000).toFixed(1)}s` : '';
const itemCount = agent.itemCount ? `${agent.itemCount} items` : '';
html += `
<div class="process-item">
<span class="process-mode ${statusClass}">${agent.status || 'pending'}</span>
<div class="process-info">
<div class="process-name">${modelBadge} ${escapeHtml(agentLabel)}</div>
<div class="process-meta">
${detail ? `<span>${detail}</span>` : ''}
${itemCount ? `<span>${itemCount}</span>` : ''}
${duration ? `<span>${duration}</span>` : ''}
</div>
</div>
</div>
`;
}
body.innerHTML = html;
}
async cancelPlanFromMonitor() {
if (!this.activePlanOrchestratorId && this.planSubagents?.size === 0) {
this.showToast('No active plan generation', 'info');
return;
}
if (!confirm('Cancel plan generation and close all plan agent windows?')) return;
// Cancel the plan generation (reuse existing method)
await this.cancelPlanGeneration();
// Also force close the wizard if it's open
const wizardModal = document.getElementById('ralphWizardModal');
if (wizardModal?.classList.contains('active')) {
this.closeRalphWizard();
}
// Update monitor display
this.renderMonitorPlanAgents();
this.showToast('Plan generation cancelled', 'success');
}
// ═══════════════════════════════════════════════════════════════
// Toast
// ═══════════════════════════════════════════════════════════════
// Cached toast container for performance
_toastContainer = null;
toggleNotifications() {
this.notificationManager?.toggleDrawer();
}
// Alias for showToast
toast(message, type = 'info') {
return this.showToast(message, type);
}
showToast(message, type = 'info', opts = {}) {
const { duration = 3000, action } = opts;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const msgSpan = document.createElement('span');
msgSpan.textContent = message;
toast.appendChild(msgSpan);
if (action) {
const btn = document.createElement('button');
btn.textContent = action.label;
btn.style.cssText = 'margin-left:12px;padding:2px 10px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);border-radius:3px;color:inherit;cursor:pointer;font-size:12px';
btn.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); };
toast.appendChild(btn);
}
// Cache toast container reference
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
this._toastContainer.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 200);
}, duration);
}
// ═══════════════════════════════════════════════════════════════
// System Stats
// ═══════════════════════════════════════════════════════════════
startSystemStatsPolling() {
// Clear any existing interval to prevent duplicates
this.stopSystemStatsPolling();
// Initial fetch
this.fetchSystemStats();
// Poll every 2 seconds
this.systemStatsInterval = setInterval(() => {
this.fetchSystemStats();
}, 2000);
}
stopSystemStatsPolling() {
if (this.systemStatsInterval) {
clearInterval(this.systemStatsInterval);
this.systemStatsInterval = null;
}
}
async fetchSystemStats() {
// Skip polling when system stats display is hidden
const statsEl = document.getElementById('headerSystemStats');
if (!statsEl || statsEl.style.display === 'none') return;
try {
const res = await fetch('/api/system/stats');
const stats = await res.json();
this.updateSystemStatsDisplay(stats);
} catch (err) {
// Silently fail - system stats are not critical
}
}
updateSystemStatsDisplay(stats) {
const cpuEl = this.$('statCpu');
const cpuBar = this.$('statCpuBar');
const memEl = this.$('statMem');
const memBar = this.$('statMemBar');
if (cpuEl && cpuBar) {
cpuEl.textContent = `${stats.cpu}%`;
cpuBar.style.width = `${Math.min(100, stats.cpu)}%`;
// Color classes based on usage
cpuBar.classList.remove('medium', 'high');
cpuEl.classList.remove('high');
if (stats.cpu > 80) {
cpuBar.classList.add('high');
cpuEl.classList.add('high');
} else if (stats.cpu > 50) {
cpuBar.classList.add('medium');
}
}
if (memEl && memBar) {
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
memEl.textContent = `${memGB}G`;
memBar.style.width = `${Math.min(100, stats.memory.percent)}%`;
// Color classes based on usage
memBar.classList.remove('medium', 'high');
memEl.classList.remove('high');
if (stats.memory.percent > 80) {
memBar.classList.add('high');
memEl.classList.add('high');
} else if (stats.memory.percent > 50) {
memBar.classList.add('medium');
}
}
}
}
// ═══════════════════════════════════════════════════════════════
// Module Init — localStorage migration and app start
// ═══════════════════════════════════════════════════════════════
// Migrate legacy localStorage keys (claudeman-* → codeman-*)
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('claudeman-') || key.startsWith('claudeman_'))) {
const newKey = key.replace(/^claudeman[-_]/, (m) => 'codeman' + m.charAt(m.length - 1));
if (localStorage.getItem(newKey) === null) {
localStorage.setItem(newKey, localStorage.getItem(key));
}
}
}
} catch {}
// Initialize
const app = new CodemanApp();
// Expose for debugging/testing
window.app = app;
window.MobileDetection = MobileDetection;